From 288a56150751fe6d90b48e610dd34b15671f421c Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:14:08 -0700 Subject: [PATCH 01/28] feat(types): add live_preview capability and pending_aggregation status - Add live_preview: bool = True field to StreamCapabilities for routing Go3S streams to standalone recorder panel (defaults to True for backward compatibility) - Include live_preview in to_dict() serialization - Extend FinalizationReport.status Literal with pending_aggregation value to express background WiFi aggregation jobs - Create comprehensive test files test_types_capabilities.py and test_types_finalization.py with full coverage - Update existing test_types.py tests to expect live_preview in dict Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/types.py | 12 ++++++++++-- tests/unit/test_types.py | 2 ++ tests/unit/test_types_capabilities.py | 18 ++++++++++++++++++ tests/unit/test_types_finalization.py | 16 ++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_types_capabilities.py create mode 100644 tests/unit/test_types_finalization.py diff --git a/src/syncfield/types.py b/src/syncfield/types.py index 1e9b924..263a379 100644 --- a/src/syncfield/types.py +++ b/src/syncfield/types.py @@ -173,12 +173,15 @@ class StreamCapabilities: (wireless, USB unplug); the orchestrator treats it more defensively. produces_file: True if the stream writes a file (e.g. video) rather than an in-memory sample stream. + live_preview: True if the stream should route to a live video preview + (rather than a standalone recorder panel). Defaults to True. """ provides_audio_track: bool = False supports_precise_timestamps: bool = False is_removable: bool = False produces_file: bool = False + live_preview: bool = True def to_dict(self) -> dict[str, Any]: return { @@ -186,6 +189,7 @@ def to_dict(self) -> dict[str, Any]: "supports_precise_timestamps": self.supports_precise_timestamps, "is_removable": self.is_removable, "produces_file": self.produces_file, + "live_preview": self.live_preview, } @@ -280,7 +284,11 @@ class FinalizationReport: Attributes: stream_id: Stream that was finalized. - status: One of ``"completed"``, ``"partial"``, ``"failed"``. + status: One of ``"completed"``, ``"partial"``, ``"failed"``, + ``"pending_aggregation"``. The ``"pending_aggregation"`` status + indicates the stream finished its synchronous lifecycle but a + background aggregation job is still required to land all artifacts + on disk. frame_count: Number of samples/frames produced. file_path: Path to any file the stream wrote, or None. first_sample_at_ns: Monotonic ns of first sample, or None if empty. @@ -296,7 +304,7 @@ class FinalizationReport: """ stream_id: str - status: Literal["completed", "partial", "failed"] + status: Literal["completed", "partial", "failed", "pending_aggregation"] frame_count: int file_path: Path | None first_sample_at_ns: int | None diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 7fe4e89..ed5d368 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -167,6 +167,7 @@ def test_default_all_false(self): assert caps.supports_precise_timestamps is False assert caps.is_removable is False assert caps.produces_file is False + assert caps.live_preview is True def test_to_dict_round_trip(self): caps = StreamCapabilities( @@ -181,6 +182,7 @@ def test_to_dict_round_trip(self): "supports_precise_timestamps": False, "is_removable": True, "produces_file": True, + "live_preview": True, } diff --git a/tests/unit/test_types_capabilities.py b/tests/unit/test_types_capabilities.py new file mode 100644 index 0000000..755cf37 --- /dev/null +++ b/tests/unit/test_types_capabilities.py @@ -0,0 +1,18 @@ +from syncfield.types import StreamCapabilities + + +def test_live_preview_defaults_to_true(): + caps = StreamCapabilities() + assert caps.live_preview is True + + +def test_live_preview_can_be_disabled(): + caps = StreamCapabilities(live_preview=False) + assert caps.live_preview is False + + +def test_to_dict_includes_live_preview(): + caps = StreamCapabilities(live_preview=False) + d = caps.to_dict() + assert d["live_preview"] is False + assert d["produces_file"] is False diff --git a/tests/unit/test_types_finalization.py b/tests/unit/test_types_finalization.py new file mode 100644 index 0000000..a401716 --- /dev/null +++ b/tests/unit/test_types_finalization.py @@ -0,0 +1,16 @@ +from pathlib import Path +from syncfield.types import FinalizationReport + + +def test_finalization_report_accepts_pending_aggregation_status(): + report = FinalizationReport( + stream_id="overhead", + status="pending_aggregation", + frame_count=0, + file_path=None, + first_sample_at_ns=None, + last_sample_at_ns=None, + health_events=[], + error=None, + ) + assert report.status == "pending_aggregation" From 3b199e68c6ecb5fa08e29c277ba00beb1d1f4bc1 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:17:35 -0700 Subject: [PATCH 02/28] feat(adapters): scaffold insta360_go3s package --- src/syncfield/adapters/insta360_go3s/__init__.py | 1 + src/syncfield/adapters/insta360_go3s/aggregation/__init__.py | 1 + src/syncfield/adapters/insta360_go3s/ble/__init__.py | 1 + src/syncfield/adapters/insta360_go3s/wifi/__init__.py | 1 + tests/integration/insta360_go3s/__init__.py | 0 tests/unit/adapters/insta360_go3s/__init__.py | 0 6 files changed, 4 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/__init__.py create mode 100644 src/syncfield/adapters/insta360_go3s/aggregation/__init__.py create mode 100644 src/syncfield/adapters/insta360_go3s/ble/__init__.py create mode 100644 src/syncfield/adapters/insta360_go3s/wifi/__init__.py create mode 100644 tests/integration/insta360_go3s/__init__.py create mode 100644 tests/unit/adapters/insta360_go3s/__init__.py diff --git a/src/syncfield/adapters/insta360_go3s/__init__.py b/src/syncfield/adapters/insta360_go3s/__init__.py new file mode 100644 index 0000000..6406a18 --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/__init__.py @@ -0,0 +1 @@ +"""Insta360 Go3S adapter (BLE trigger + WiFi aggregation).""" diff --git a/src/syncfield/adapters/insta360_go3s/aggregation/__init__.py b/src/syncfield/adapters/insta360_go3s/aggregation/__init__.py new file mode 100644 index 0000000..93ac4d7 --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/aggregation/__init__.py @@ -0,0 +1 @@ +"""Background aggregation queue for Insta360 Go3S episodes.""" diff --git a/src/syncfield/adapters/insta360_go3s/ble/__init__.py b/src/syncfield/adapters/insta360_go3s/ble/__init__.py new file mode 100644 index 0000000..439a7c9 --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/ble/__init__.py @@ -0,0 +1 @@ +"""BLE protocol and camera control for Insta360 Go3S.""" diff --git a/src/syncfield/adapters/insta360_go3s/wifi/__init__.py b/src/syncfield/adapters/insta360_go3s/wifi/__init__.py new file mode 100644 index 0000000..3e402ae --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/wifi/__init__.py @@ -0,0 +1 @@ +"""WiFi switching and OSC HTTP client for Insta360 Go3S.""" diff --git a/tests/integration/insta360_go3s/__init__.py b/tests/integration/insta360_go3s/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/adapters/insta360_go3s/__init__.py b/tests/unit/adapters/insta360_go3s/__init__.py new file mode 100644 index 0000000..e69de29 From 03d3e1725f070844c11513a2794da73e59c834fc Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:21:17 -0700 Subject: [PATCH 03/28] feat(go3s/ble): port FFFrame protocol from recorder Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/insta360_go3s/ble/protocol.py | 307 ++++++++++++++++++ .../insta360_go3s/test_ble_protocol.py | 91 ++++++ 2 files changed, 398 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/ble/protocol.py create mode 100644 tests/unit/adapters/insta360_go3s/test_ble_protocol.py diff --git a/src/syncfield/adapters/insta360_go3s/ble/protocol.py b/src/syncfield/adapters/insta360_go3s/ble/protocol.py new file mode 100644 index 0000000..254a05e --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/ble/protocol.py @@ -0,0 +1,307 @@ +"""Insta360 Go2BlePacket (FFFrame) protocol. + +Ported verbatim from: + syncfield_recorder/sensors/insta360_ble/protocol.py + +Which was reverse-engineered from xaionaro-go/insta360ctl and validated +on 3x Insta360 GO 3S (firmware v8.0.4.11). + +Ref: github.com/xaionaro-go/insta360ctl/doc/ble_protocol.md +""" +from __future__ import annotations + +import struct +from dataclasses import dataclass + +# BLE GATT UUIDs +SERVICE_UUID = "0000be80-0000-1000-8000-00805f9b34fb" +WRITE_CHAR_UUID = "0000be81-0000-1000-8000-00805f9b34fb" +NOTIFY_CHAR_UUID = "0000be82-0000-1000-8000-00805f9b34fb" + +# FFFrame constants +FF_MARKER = 0xFF +TYPE_APP_TO_CAM = 0x07 +TYPE_CAM_TO_APP = 0x06 +SUBTYPE_MESSAGE = 0x40 +SUBTYPE_SYNC = 0x41 + +# Command codes (phone -> camera) +CMD_TAKE_PICTURE = 0x0003 +CMD_START_CAPTURE = 0x0004 +CMD_STOP_CAPTURE = 0x0005 +CMD_SET_OPTIONS = 0x0002 +CMD_CHECK_AUTH = 0x0027 +CMD_REQUEST_AUTH = 0x0056 + +# Response status codes +STATUS_OK = 0x00C8 +STATUS_BAD_REQUEST = 0x0190 +STATUS_ERROR = 0x01F4 +STATUS_NOT_IMPL = 0x01F5 + +# Outer header size: FF_MARKER(1) + type(1) + subtype(1) + payload_len(2) +_OUTER_HDR_LEN = 5 + +# Inner header size (Go2BlePacket fixed header) +_INNER_HDR_LEN = 16 + + +def crc16_modbus(data: bytes | bytearray) -> int: + """CRC-16/MODBUS: polynomial 0xA001, init 0xFFFF, little-endian.""" + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 0x0001: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc + + +def _append_crc(packet: bytes) -> bytes: + return packet + struct.pack(" bytes: + """Build SYNC response: FF 07 41 [len=7] [7 zero bytes] [CRC].""" + header = struct.pack(" bytes: + """Build Go2BlePacket command with 16-byte inner header + optional protobuf.""" + inner_size = _INNER_HDR_LEN + len(protobuf_payload) + hdr = bytearray(_INNER_HDR_LEN) + struct.pack_into("cam + + inner = bytes(hdr) + protobuf_payload + outer = struct.pack(" bytes: + """Build a Go2BlePacket command frame (public API alias for build_command). + + Args: + cmd: Command code (e.g. CMD_START_CAPTURE). + seq: Sequence counter (0–255, wraps). + protobuf_payload: Optional serialised protobuf body. + """ + return build_command(cmd_code=cmd, seq=seq, protobuf_payload=protobuf_payload) + + +def build_start_capture_pb(mode: int = 1) -> bytes: + """Protobuf payload for CMD_START_CAPTURE with capture mode. + + Args: + mode: 1 = INSCaptureModeNormal (standard video). + iOS SDK: captureMode.mode = 1 + """ + # protobuf field 1, wire type 0 (varint), value = mode + return bytes([0x08, mode & 0x7F]) + + +def build_video_mode_options_pb() -> bytes: + """Protobuf payload for CMD_SET_OPTIONS to force video normal mode. + + Sets videoSubMode=0 (Normal) via option type 41. + iOS SDK: setOptions:forTypes: with INSCameraOptionsTypeVideoSubMode(41). + """ + # Nested message: field 1 (varint) = type 41, field 2 (varint) = value 0 + return bytes([0x0A, 0x04, 0x08, 0x29, 0x10, 0x00]) + + +def build_check_auth_pb(device_id: str) -> bytes: + """Protobuf: field 1 (string) = auth_id, field 2 (varint) = 2 (APP).""" + aid = device_id.encode("utf-8") + return bytes([0x0A, len(aid)]) + aid + bytes([0x10, 0x02]) + + +def build_check_auth_payload(addr: bytes | str) -> bytes: + """Build the CheckAuth protobuf payload for a given device address. + + Accepts either a raw bytes address or a str (encoded as UTF-8). + Public alias used by T04 and test suite. + """ + if isinstance(addr, str): + aid = addr.encode("utf-8") + else: + aid = addr + return bytes([0x0A, len(aid)]) + aid + bytes([0x10, 0x02]) + + +def build_heartbeat(seq: int, device_id: str) -> bytes: + """Build a heartbeat packet using CheckAuth as keepalive. + + The iOS SDK sends SCMP HeartBeat (0x05) every 0.5s. CMD_CHECK_AUTH + is proven to work on GO 3S without side effects and keeps the + BLE connection alive. + """ + pb = build_check_auth_pb(device_id) + return build_command(CMD_CHECK_AUTH, seq, pb) + + +@dataclass +class BLEResponse: + """Parsed BLE response from camera (recorder-compatible dataclass).""" + + raw: bytes + is_sync: bool = False + status_code: int = 0 + seq: int = 0 + video_path: str | None = None + + @property + def is_ok(self) -> bool: + return self.status_code == STATUS_OK + + @property + def is_error(self) -> bool: + return self.status_code >= STATUS_BAD_REQUEST + + +@dataclass +class ParsedResponse: + """Structured parse of a camera->app FFFrame response packet. + + The camera does NOT echo the request's cmd_code. Instead, the inner[7:9] + slot that carries cmd_code in app->cam requests is repurposed to carry + the status_code in cam->app responses. Correlate responses to requests + purely by ``seq``. + + Attributes: + seq: Sequence number echoed from the request (inner[10]). + status: Status code (2 bytes LE at inner[7:9]; 0x00C8 = OK). + payload: Protobuf payload past the 16-byte inner header. + """ + + seq: int + status: int + payload: bytes + + +@dataclass +class ParsedRequest: + """Structured parse of an app->camera FFFrame request packet. + + Attributes: + cmd: Command code (2 bytes LE at inner[7:9]). + seq: Sequence number (1 byte at inner[10]). + payload: Protobuf payload past the 16-byte inner header. + """ + + cmd: int + seq: int + payload: bytes + + +def parse_response(data: bytes) -> BLEResponse | None: + """Parse a Go2BlePacket response from camera (recorder-compatible API).""" + if len(data) < 3 or data[0] != FF_MARKER: + return None + + if data[2] == SUBTYPE_SYNC: + return BLEResponse(raw=data, is_sync=True) + + if data[2] == SUBTYPE_MESSAGE and len(data) >= 14: + status_code = struct.unpack_from(" 15 else 0 + + video_path = None + try: + text = data.decode("ascii", errors="replace") + if "/DCIM/" in text: + start = text.index("/DCIM/") + # Try .mp4 first, then .insv + end = -1 + for ext in (".mp4", ".insv"): + try: + end = text.index(ext, start) + len(ext) + break + except ValueError: + continue + if end > start: + video_path = text[start:end] + except ValueError: + pass + + return BLEResponse( + raw=data, status_code=status_code, seq=seq, video_path=video_path + ) + + return BLEResponse(raw=data) + + +def parse_response_packet(pkt: bytes) -> ParsedResponse | None: + """Parse a camera->app FFFrame packet into a structured ParsedResponse. + + FFFrame outer header layout: + [0] FF_MARKER (0xFF) + [1] frame type (0x06 = cam->app) + [2] subtype (0x40 = message, 0x41 = sync) + [3:5] payload length (LE uint16) + [5:] inner payload (Go2BlePacket header + protobuf) + [-2:] CRC-16/MODBUS over all preceding bytes + + Inner header layout for cam->app responses (16 bytes, at offset 5): + [0:4] total inner size (LE uint32) + [4] mode + [5:7] reserved + [7:9] status_code (LE uint16) <-- reused slot; in app->cam this is cmd_code + [9] content_type + [10] seq + [11:16] reserved / flags + [16:] protobuf payload + + Note: the camera does NOT echo the request cmd_code. Correlate by seq. + This mirrors the recorder's validated ``parse_response`` byte offsets + (``status_code`` at packet offset 12 = inner offset 7). + + Returns None if the packet is too short, has a bad marker, or is a SYNC. + """ + if len(pkt) < _OUTER_HDR_LEN + _INNER_HDR_LEN + 2: + return None + if pkt[0] != FF_MARKER: + return None + if pkt[2] != SUBTYPE_MESSAGE: + return None + + base = _OUTER_HDR_LEN # inner header starts here + status = struct.unpack_from(" ParsedRequest | None: + """Parse an app->camera FFFrame request packet into a ParsedRequest. + + Uses the same outer + inner header layout as parse_response_packet, + but expects frame type 0x07 (app->cam). + + Returns None if the packet is too short, has a bad marker, or is a SYNC. + """ + if len(pkt) < _OUTER_HDR_LEN + _INNER_HDR_LEN + 2: + return None + if pkt[0] != FF_MARKER: + return None + if pkt[2] != SUBTYPE_MESSAGE: + return None + + base = _OUTER_HDR_LEN + cmd = struct.unpack_from("cam), subtype 0x40. + + Inner header is 16 bytes; CRC-16 trails.""" + pkt = p.build_message_packet( + cmd=p.CMD_START_CAPTURE, + seq=1, + protobuf_payload=b"\x08\x01", + ) + assert pkt[0] == 0xFF + assert pkt[1] == 0x07 + assert pkt[2] == 0x40 + body = pkt[:-2] + crc = int.from_bytes(pkt[-2:], "little") + assert crc == p.crc16_modbus(body) + + +def test_build_sync_response_is_constant(): + sync = p.build_sync_response() + assert sync[0] == 0xFF + assert sync[1] == 0x07 + assert sync[2] == 0x41 # SUBTYPE_SYNC + + +def test_build_check_auth_payload_format(): + """auth_id is wrapped: [0x0A, len(addr)] + addr + [0x10, 0x02].""" + addr = b"AA:BB:CC:DD:EE:FF" + pb = p.build_check_auth_payload(addr) + assert pb[0] == 0x0A + assert pb[1] == len(addr) + assert pb[2 : 2 + len(addr)] == addr + assert pb[-2:] == b"\x10\x02" + + +def test_parse_response_extracts_seq_and_status(): + """A camera response (type 0x06) carries status at inner[7:9] (LE) and + seq at inner[10], inside the 16-byte inner header that follows the + 5-byte FFFrame outer header. + + The camera does NOT echo the request's cmd_code; the inner[7:9] slot + that holds cmd_code in app->cam requests is reused for status_code in + cam->app responses. Correlation is by seq only. + """ + inner = bytearray(16) + inner[4] = 0x04 # mode + inner[7:9] = p.STATUS_OK.to_bytes(2, "little") # status_code (LE) = 0x00C8 + inner[9] = 0x02 # content_type + inner[10] = 1 # seq + payload = bytes(inner) + outer_no_crc = ( + bytes([0xFF, 0x06, 0x40]) + + len(payload).to_bytes(2, "little") + + payload + ) + pkt = outer_no_crc + p.crc16_modbus(outer_no_crc).to_bytes(2, "little") + parsed = p.parse_response_packet(pkt) + assert parsed.seq == 1 + assert parsed.status == p.STATUS_OK From 10a7ed9fca5e5ba845ebf1fc8fce1814214440b6 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:30:19 -0700 Subject: [PATCH 04/28] feat(go3s/ble): port async BLE camera helper Implements Go3SBLECamera with Future-per-seq dispatch, SYNC handshake, CMD_CHECK_AUTH auth, start/stop capture, and CaptureResult. Adds pytest-asyncio and asyncio_mode=auto to pyproject.toml. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 + .../adapters/insta360_go3s/ble/camera.py | 312 ++++++++++++++++++ .../adapters/insta360_go3s/test_ble_camera.py | 135 ++++++++ uv.lock | 47 +++ 4 files changed, 496 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/ble/camera.py create mode 100644 tests/unit/adapters/insta360_go3s/test_ble_camera.py diff --git a/pyproject.toml b/pyproject.toml index b379f8c..5564a07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ packages = ["src/syncfield"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +asyncio_mode = "auto" markers = [ "hardware: tests that require physical hardware (cameras, BLE devices)", "slow: integration tests that touch real IO (mDNS sockets, filesystem, etc.)", @@ -90,6 +91,7 @@ markers = [ [dependency-groups] dev = [ "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", "pytest-mock>=3.12.0", "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.0", diff --git a/src/syncfield/adapters/insta360_go3s/ble/camera.py b/src/syncfield/adapters/insta360_go3s/ble/camera.py new file mode 100644 index 0000000..c652346 --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/ble/camera.py @@ -0,0 +1,312 @@ +"""Insta360 GO 3S async BLE camera helper. + +Public surface: + CaptureResult -- dataclass returned by stop_capture() + Go3SBLECamera -- connect/start/stop/disconnect lifecycle + +Ported from: + syncfield_recorder/sensors/insta360_ble/camera.py (production-validated) + +Key differences from the recorder's GO3SCamera: + - Address is a str (not a BLEDevice) — the stream layer resolves devices. + - _send() uses asyncio.Future keyed by seq rather than a shared Event, + so concurrent (interleaved) commands are safe. + - No heartbeat loop — the stream layer wraps connect/command/disconnect + per BLE event, so the connection is short-lived. + - start_capture() returns ack_host_ns (int, monotonic_ns) instead of + a BLEResponse, and stop_capture() returns a CaptureResult. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass +from typing import Dict, Optional + +from bleak import BleakClient + +from syncfield.adapters.insta360_go3s.ble.protocol import ( + CMD_CHECK_AUTH, + CMD_SET_OPTIONS, + CMD_START_CAPTURE, + CMD_STOP_CAPTURE, + NOTIFY_CHAR_UUID, + STATUS_OK, + WRITE_CHAR_UUID, + build_check_auth_payload, + build_message_packet, + build_start_capture_pb, + build_sync_response, + build_video_mode_options_pb, + parse_response, + parse_response_packet, +) + +logger = logging.getLogger(__name__) + +# ────────────────────────────────────────────────────────────────────────────── +# Public dataclasses +# ────────────────────────────────────────────────────────────────────────────── + + +@dataclass +class CaptureResult: + """Result of a stop_capture() call. + + Attributes: + file_path: Absolute path to the recorded video file on the camera + (e.g. ``/DCIM/Camera01/VID_20240101_120000.mp4``). + ack_host_ns: ``time.monotonic_ns()`` captured immediately after the + stop-capture ACK was received. + """ + + file_path: str + ack_host_ns: int + + +# ────────────────────────────────────────────────────────────────────────────── +# Camera helper +# ────────────────────────────────────────────────────────────────────────────── + + +class Go3SBLECamera: + """Controls a single Insta360 GO 3S via BLE. + + Lifecycle:: + + cam = Go3SBLECamera("AA:BB:CC:DD:EE:FF") + await cam.connect() + ack_ns = await cam.start_capture() + result = await cam.stop_capture() + await cam.disconnect() + """ + + def __init__(self, address: str) -> None: + self._address = address + self._client: Optional[BleakClient] = None + + # Sequence counter: wraps 1–254 (skip 0 and 255). + self._seq: int = 1 + + # Pending send/receive futures keyed by seq. + self._pending_acks: Dict[int, asyncio.Future] = {} + + # Set when a SYNC frame is received from the camera. + self._sync_received_event: asyncio.Event = asyncio.Event() + + # Raw bytes of the last notify frame (kept for video-path scanning). + self._last_raw: Optional[bytes] = None + + # ── properties ──────────────────────────────────────────────────────────── + + @property + def is_connected(self) -> bool: + """True when the underlying BleakClient reports a live connection.""" + if self._client is None: + return False + return bool(self._client.is_connected) + + # ── public API ──────────────────────────────────────────────────────────── + + async def connect( + self, + *, + sync_timeout: float = 2.0, + auth_timeout: float = 1.0, + ) -> None: + """Open BLE connection and complete the SYNC + auth handshake. + + Steps: + 1. Connect via BleakClient. + 2. Subscribe to notifications (this is when the camera sends SYNC). + 3. Wait up to *sync_timeout* for SYNC; if it doesn't arrive, nudge + the camera with a single ``0x00`` trigger byte. + 4. Send the SYNC response packet (camera expects it). + 5. Send CMD_CHECK_AUTH and wait up to *auth_timeout* for STATUS_OK. + """ + self._sync_received_event.clear() + self._pending_acks.clear() + + self._client = BleakClient(self._address) + await self._client.connect() + logger.debug("[Go3SBLECamera] Connected to %s", self._address) + + # Subscribe — FakeBleakClient (and real cameras) emit SYNC here. + await self._client.start_notify(NOTIFY_CHAR_UUID, self._on_notify) + + # ── Wait for SYNC ────────────────────────────────────────────────── + try: + await asyncio.wait_for( + self._sync_received_event.wait(), timeout=sync_timeout + ) + except (asyncio.TimeoutError, TimeoutError): + logger.debug("[Go3SBLECamera] SYNC timeout; sending trigger byte") + await self._client.write_gatt_char( + WRITE_CHAR_UUID, bytes([0x00]), response=False + ) + try: + await asyncio.wait_for( + self._sync_received_event.wait(), timeout=1.0 + ) + except (asyncio.TimeoutError, TimeoutError): + logger.warning("[Go3SBLECamera] No SYNC received; continuing") + + # ── SYNC response ────────────────────────────────────────────────── + await self._client.write_gatt_char( + WRITE_CHAR_UUID, build_sync_response(), response=True + ) + + # ── Auth ─────────────────────────────────────────────────────────── + auth_payload = build_check_auth_payload(self._address) + try: + await self._send(CMD_CHECK_AUTH, auth_payload, timeout=auth_timeout) + logger.debug("[Go3SBLECamera] Auth OK") + except Exception as exc: + logger.warning("[Go3SBLECamera] Auth failed: %s", exc) + + async def set_video_mode(self) -> None: + """Send SET_OPTIONS to enforce video-normal mode (best-effort).""" + pb = build_video_mode_options_pb() + await self._send(CMD_SET_OPTIONS, pb, timeout=5.0) + logger.debug("[Go3SBLECamera] Video mode set") + + async def start_capture(self) -> int: + """Start recording. + + Returns: + ack_host_ns: ``time.monotonic_ns()`` captured immediately after + the ACK is received (not before the command is sent). + """ + pb = build_start_capture_pb(mode=1) # INSCaptureModeNormal + await self._send(CMD_START_CAPTURE, pb, timeout=5.0) + ack_host_ns = time.monotonic_ns() + logger.debug("[Go3SBLECamera] Recording started (ack_ns=%d)", ack_host_ns) + return ack_host_ns + + async def stop_capture(self) -> CaptureResult: + """Stop recording. + + Returns: + CaptureResult with the video file path and ACK timestamp. + """ + raw = await self._send_raw(CMD_STOP_CAPTURE, b"", timeout=10.0) + ack_host_ns = time.monotonic_ns() + + # Use the legacy parse_response() which already scans for /DCIM/... + resp = parse_response(raw) if raw is not None else None + file_path = (resp.video_path if resp is not None else None) or "" + + logger.debug("[Go3SBLECamera] Recording stopped; file=%s", file_path) + return CaptureResult(file_path=file_path, ack_host_ns=ack_host_ns) + + async def disconnect(self) -> None: + """Tear down the BLE connection cleanly.""" + if self._client is not None: + try: + await self._client.stop_notify(NOTIFY_CHAR_UUID) + except Exception: + pass + try: + await self._client.disconnect() + except Exception: + pass + logger.debug("[Go3SBLECamera] Disconnected") + + # ── private helpers ─────────────────────────────────────────────────────── + + def _next_seq(self) -> int: + """Return the next sequence number in [1, 254], wrapping.""" + seq = self._seq + # Advance: wrap 254 → 1, otherwise increment. + self._seq = (self._seq % 254) + 1 + return seq + + def _on_notify(self, handle: int, data: bytearray) -> None: + """BleakClient notification callback — dispatches to pending futures.""" + raw = bytes(data) + self._last_raw = raw + + if len(raw) < 3 or raw[0] != 0xFF: + logger.debug("[Go3SBLECamera] Ignoring short/bad notify frame") + return + + # SYNC frame (subtype 0x41): signal connect() to proceed. + if raw[2] == 0x41: + logger.debug("[Go3SBLECamera] SYNC received") + self._sync_received_event.set() + return + + # Try structured parse first (gives us seq + status cleanly). + parsed = parse_response_packet(raw) + if parsed is not None: + seq = parsed.seq + fut = self._pending_acks.get(seq) + if fut is not None and not fut.done(): + fut.set_result((parsed, raw)) + else: + logger.debug( + "[Go3SBLECamera] Unsolicited or duplicate response seq=%d status=0x%04X", + seq, + parsed.status, + ) + return + + # Fallback: legacy parse (handles malformed/short frames). + legacy = parse_response(raw) + if legacy is not None and not legacy.is_sync: + seq = legacy.seq + fut = self._pending_acks.get(seq) + if fut is not None and not fut.done(): + fut.set_result((None, raw)) + + async def _send( + self, + cmd: int, + payload: bytes, + timeout: float = 2.0, + ) -> None: + """Send a command and wait for a STATUS_OK ACK. + + Raises: + asyncio.TimeoutError: if no response arrives within *timeout*. + RuntimeError: if the camera returns a non-OK status code. + """ + await self._send_raw(cmd, payload, timeout=timeout) + + async def _send_raw( + self, + cmd: int, + payload: bytes, + timeout: float = 2.0, + ) -> Optional[bytes]: + """Send a command, wait for ACK, and return the raw notify bytes. + + Raises: + asyncio.TimeoutError: if no response arrives within *timeout*. + RuntimeError: if the camera returns a non-OK status code. + """ + assert self._client is not None, "Not connected" + + seq = self._next_seq() + loop = asyncio.get_event_loop() + fut: asyncio.Future = loop.create_future() + self._pending_acks[seq] = fut + + pkt = build_message_packet(cmd=cmd, seq=seq, protobuf_payload=payload) + try: + await self._client.write_gatt_char(WRITE_CHAR_UUID, pkt, response=True) + parsed_tuple = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout) + except (asyncio.TimeoutError, TimeoutError) as exc: + raise asyncio.TimeoutError( + f"No response for cmd=0x{cmd:04X} seq={seq} within {timeout}s" + ) from exc + finally: + self._pending_acks.pop(seq, None) + + parsed, raw = parsed_tuple + if parsed is not None and parsed.status != STATUS_OK: + raise RuntimeError( + f"Camera returned status 0x{parsed.status:04X} for cmd=0x{cmd:04X}" + ) + return raw diff --git a/tests/unit/adapters/insta360_go3s/test_ble_camera.py b/tests/unit/adapters/insta360_go3s/test_ble_camera.py new file mode 100644 index 0000000..0b4f98c --- /dev/null +++ b/tests/unit/adapters/insta360_go3s/test_ble_camera.py @@ -0,0 +1,135 @@ +import asyncio +import time +from typing import Callable + +import pytest + +from syncfield.adapters.insta360_go3s.ble import protocol as p +from syncfield.adapters.insta360_go3s.ble.camera import ( + CaptureResult, + Go3SBLECamera, +) + + +class FakeBleakClient: + """Minimal in-memory bleak.BleakClient stand-in. + + Records writes; for any CMD_* request, queues a STATUS_OK response with + the matching seq via the notify callback. + """ + + def __init__(self, address: str): + self.address = address + self.is_connected = False + self._notify_cb: Callable[[int, bytearray], None] | None = None + self._write_log: list[bytes] = [] + + async def connect(self): + self.is_connected = True + + async def disconnect(self): + self.is_connected = False + + async def start_notify(self, char_uuid, callback): + assert char_uuid == p.NOTIFY_CHAR_UUID + self._notify_cb = callback + # Send SYNC immediately so the camera's connect() doesn't time out + sync_outer_no_crc = bytes([0xFF, 0x06, 0x41]) + b"\x07\x00" + b"\x00" * 9 + sync = sync_outer_no_crc + p.crc16_modbus(sync_outer_no_crc).to_bytes(2, "little") + await asyncio.sleep(0) + callback(0, bytearray(sync)) + + async def stop_notify(self, char_uuid): + self._notify_cb = None + + async def write_gatt_char(self, char_uuid, data, response=True): + assert char_uuid == p.WRITE_CHAR_UUID + self._write_log.append(bytes(data)) + parsed = p.parse_request_packet(bytes(data)) + if parsed is None: + return + if parsed.cmd in ( + p.CMD_CHECK_AUTH, + p.CMD_START_CAPTURE, + p.CMD_STOP_CAPTURE, + p.CMD_SET_OPTIONS, + ): + resp = self._build_ok_response(parsed.seq, with_filename=parsed.cmd == p.CMD_STOP_CAPTURE) + assert self._notify_cb is not None + self._notify_cb(0, bytearray(resp)) + + @staticmethod + def _build_ok_response(seq: int, with_filename: bool) -> bytes: + # Build inner header: status (LE) at inner[7:9], seq at inner[10]. + inner = bytearray(16) + inner[4] = 0x04 # mode + inner[7:9] = p.STATUS_OK.to_bytes(2, "little") # status, NOT cmd_code + inner[9] = 0x02 # content_type + inner[10] = seq + # Optional ASCII video path embedded in payload (recorder scans data for /DCIM/...) + pb = b"" + if with_filename: + pb = b"/DCIM/Camera01/VID_FAKE.mp4\x00" + payload = bytes(inner) + pb + outer = ( + bytes([0xFF, 0x06, 0x40]) + + len(payload).to_bytes(2, "little") + + payload + ) + return outer + p.crc16_modbus(outer).to_bytes(2, "little") + + +@pytest.fixture +def fake_client(monkeypatch): + instances: list[FakeBleakClient] = [] + + def factory(address, *args, **kwargs): + c = FakeBleakClient(address) + instances.append(c) + return c + + monkeypatch.setattr( + "syncfield.adapters.insta360_go3s.ble.camera.BleakClient", + factory, + ) + return instances + + +@pytest.mark.asyncio +async def test_connect_runs_sync_and_auth(fake_client): + cam = Go3SBLECamera(address="AA:BB:CC:DD:EE:FF") + await cam.connect(sync_timeout=2.0, auth_timeout=2.0) + assert fake_client[0].is_connected + # First write should be the SYNC response (subtype 0x41) + first = fake_client[0]._write_log[0] + assert first[2] == 0x41 + # Subsequent writes should include CMD_CHECK_AUTH at least once + cmd_codes = [] + for w in fake_client[0]._write_log: + if w[2] == 0x40: + req = p.parse_request_packet(w) + if req is not None: + cmd_codes.append(req.cmd) + assert p.CMD_CHECK_AUTH in cmd_codes + await cam.disconnect() + + +@pytest.mark.asyncio +async def test_start_capture_returns_host_ns(fake_client): + cam = Go3SBLECamera(address="AA:BB:CC:DD:EE:FF") + await cam.connect() + before = time.monotonic_ns() + ack_ns = await cam.start_capture() + after = time.monotonic_ns() + assert before <= ack_ns <= after + await cam.disconnect() + + +@pytest.mark.asyncio +async def test_stop_capture_returns_filepath(fake_client): + cam = Go3SBLECamera(address="AA:BB:CC:DD:EE:FF") + await cam.connect() + await cam.start_capture() + result: CaptureResult = await cam.stop_capture() + assert result.file_path == "/DCIM/Camera01/VID_FAKE.mp4" + await cam.disconnect() diff --git a/uv.lock b/uv.lock index 3c5aec5..1246a32 100644 --- a/uv.lock +++ b/uv.lock @@ -164,6 +164,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "bleak" version = "1.1.1" @@ -1490,6 +1499,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-mock" version = "3.15.1" @@ -1744,6 +1788,8 @@ viewer = [ dev = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-mock" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, @@ -1781,6 +1827,7 @@ provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "multihost", "all"] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-mock", specifier = ">=3.12.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.0" }, From 98c37746f15aa5f3517e8dc6695a04fbd4809a71 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:38:13 -0700 Subject: [PATCH 05/28] feat(go3s/wifi): add OSC HTTP client with atomic download Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 1 + .../adapters/insta360_go3s/wifi/osc_client.py | 173 ++++ .../adapters/insta360_go3s/test_osc_client.py | 97 ++ uv.lock | 898 ++++++++++++++++++ 4 files changed, 1169 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/wifi/osc_client.py create mode 100644 tests/unit/adapters/insta360_go3s/test_osc_client.py diff --git a/pyproject.toml b/pyproject.toml index 5564a07..e00feb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ markers = [ [dependency-groups] dev = [ "pytest>=8.4.2", + "pytest-aiohttp>=1.1.0", "pytest-asyncio>=1.2.0", "pytest-mock>=3.12.0", "pytest-timeout>=2.4.0", diff --git a/src/syncfield/adapters/insta360_go3s/wifi/osc_client.py b/src/syncfield/adapters/insta360_go3s/wifi/osc_client.py new file mode 100644 index 0000000..545d5ed --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/wifi/osc_client.py @@ -0,0 +1,173 @@ +"""OSC (Open Spherical Camera) HTTP client for Insta360 Go3S. + +Targets the Go3S AP (default 192.168.42.1). Endpoints mirror the public +OSC spec: ``/osc/info``, ``/osc/commands/execute`` (``camera.listFiles``), +plus direct file GETs on the SD card paths the camera reports. +""" +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable + +import aiohttp + + +DEFAULT_HOST = "192.168.42.1" +FALLBACK_PORTS: tuple[int, ...] = (80, 6666, 8080) +PROGRESS_CHUNK = 64 * 1024 + + +class OscDownloadError(RuntimeError): + """Raised when an OSC file download cannot be completed atomically.""" + + +@dataclass(frozen=True) +class OscCameraInfo: + manufacturer: str + model: str + firmware_version: str + + +@dataclass(frozen=True) +class OscFileEntry: + name: str + file_url: str + size: int + + +class OscHttpClient: + def __init__( + self, + *, + host: str = DEFAULT_HOST, + scheme: str = "http", + request_timeout: float = 10.0, + ): + self._host = host + self._scheme = scheme + self._request_timeout = request_timeout + + def _url(self, path: str) -> str: + return f"{self._scheme}://{self._host}{path}" + + async def probe(self, *, timeout: float = 5.0) -> OscCameraInfo: + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=timeout) + ) as s: + async with s.get(self._url("/osc/info")) as r: + r.raise_for_status() + data = await r.json() + return OscCameraInfo( + manufacturer=data.get("manufacturer", ""), + model=data.get("model", ""), + firmware_version=data.get("firmwareVersion", ""), + ) + + async def list_files(self) -> list[OscFileEntry]: + body = { + "name": "camera.listFiles", + "parameters": {"fileType": "video", "entryCount": 100}, + } + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=self._request_timeout) + ) as s: + async with s.post(self._url("/osc/commands/execute"), json=body) as r: + r.raise_for_status() + data = await r.json() + entries = data.get("results", {}).get("entries", []) + return [ + OscFileEntry( + name=e.get("name", ""), + file_url=e.get("fileUrl", ""), + size=int(e.get("size", 0)), + ) + for e in entries + ] + + async def download( + self, + *, + remote_path: str, + local_path: Path, + expected_size: int | None = None, + on_progress: Callable[[int, int], None] | None = None, + port_overrides: Iterable[int] | None = None, + ) -> None: + """Atomically download a file from the camera SD. + + Streams to ``local_path.with_suffix(local_path.suffix + '.part')`` + and renames on success. On any failure (network, size mismatch), + deletes the partial file and raises :class:`OscDownloadError`. + """ + partial = local_path.with_suffix(local_path.suffix + ".part") + partial.parent.mkdir(parents=True, exist_ok=True) + total = expected_size or 0 + + # Determine which ports to try. + # If self._host already embeds a port (e.g. "127.0.0.1:8765") use only + # that port so test-server addresses are respected exactly. + if port_overrides is not None: + ports = list(port_overrides) + elif ":" in self._host: + _, embedded_port = self._host.rsplit(":", 1) + ports = [int(embedded_port)] + else: + ports = list(FALLBACK_PORTS) + + bare_host = self._stripped_host() + + last_error: Exception | None = None + for port in ports: + url = f"{self._scheme}://{bare_host}:{port}{remote_path}" + try: + await self._stream_to_partial(url, partial, total, on_progress) + size_on_disk = partial.stat().st_size + if expected_size is not None and size_on_disk != expected_size: + raise OscDownloadError( + f"size mismatch: got {size_on_disk}, expected {expected_size}" + ) + os.replace(partial, local_path) + return + except (aiohttp.ClientError, OscDownloadError, asyncio.TimeoutError) as e: + last_error = e + if partial.exists(): + partial.unlink(missing_ok=True) + continue + + if partial.exists(): + partial.unlink(missing_ok=True) + raise OscDownloadError( + f"all download attempts failed for {remote_path}: {last_error}" + ) + + async def _stream_to_partial( + self, + url: str, + partial: Path, + expected_total: int, + on_progress: Callable[[int, int], None] | None, + ) -> None: + timeout = aiohttp.ClientTimeout( + total=None, sock_read=60.0, sock_connect=10.0 + ) + async with aiohttp.ClientSession(timeout=timeout) as s: + async with s.get(url) as r: + r.raise_for_status() + total = expected_total or int( + r.headers.get("Content-Length", "0") or 0 + ) + done = 0 + with partial.open("wb") as fh: + async for chunk in r.content.iter_chunked(PROGRESS_CHUNK): + fh.write(chunk) + done += len(chunk) + if on_progress is not None: + on_progress(done, total) + + def _stripped_host(self) -> str: + if ":" in self._host: + return self._host.split(":", 1)[0] + return self._host diff --git a/tests/unit/adapters/insta360_go3s/test_osc_client.py b/tests/unit/adapters/insta360_go3s/test_osc_client.py new file mode 100644 index 0000000..9fb78ea --- /dev/null +++ b/tests/unit/adapters/insta360_go3s/test_osc_client.py @@ -0,0 +1,97 @@ +import json +from pathlib import Path + +import pytest +from aiohttp import web + +from syncfield.adapters.insta360_go3s.wifi.osc_client import ( + OscDownloadError, + OscHttpClient, +) + + +@pytest.fixture +async def osc_server(aiohttp_server): + """Fake OSC HTTP server that mimics the Go3S endpoints we hit.""" + + async def info(request): + return web.json_response( + {"manufacturer": "Insta360", "model": "Go 3S", "firmwareVersion": "8.0.4.11"} + ) + + async def execute(request): + body = await request.json() + if body["name"] == "camera.listFiles": + return web.json_response( + { + "results": { + "entries": [ + { + "name": "VID_FAKE.mp4", + "fileUrl": "/DCIM/Camera01/VID_FAKE.mp4", + "size": 12, + } + ] + }, + "state": "done", + } + ) + return web.json_response({"state": "error"}, status=400) + + async def get_file(request): + return web.Response(body=b"hello world!", headers={"Content-Length": "12"}) + + app = web.Application() + app.router.add_get("/osc/info", info) + app.router.add_post("/osc/commands/execute", execute) + app.router.add_get("/DCIM/Camera01/VID_FAKE.mp4", get_file) + return await aiohttp_server(app) + + +@pytest.mark.asyncio +async def test_probe_returns_camera_model(osc_server): + client = OscHttpClient(host=f"127.0.0.1:{osc_server.port}", scheme="http") + info = await client.probe(timeout=2.0) + assert info.model == "Go 3S" + + +@pytest.mark.asyncio +async def test_list_files_returns_entries(osc_server): + client = OscHttpClient(host=f"127.0.0.1:{osc_server.port}", scheme="http") + files = await client.list_files() + assert len(files) == 1 + assert files[0].name == "VID_FAKE.mp4" + assert files[0].size == 12 + + +@pytest.mark.asyncio +async def test_download_writes_atomic_file(osc_server, tmp_path): + client = OscHttpClient(host=f"127.0.0.1:{osc_server.port}", scheme="http") + target = tmp_path / "overhead.mp4" + progress_calls: list[tuple[int, int]] = [] + + await client.download( + remote_path="/DCIM/Camera01/VID_FAKE.mp4", + local_path=target, + expected_size=12, + on_progress=lambda done, total: progress_calls.append((done, total)), + ) + + assert target.exists() + assert target.read_bytes() == b"hello world!" + assert not (tmp_path / "overhead.mp4.part").exists() + assert progress_calls[-1] == (12, 12) + + +@pytest.mark.asyncio +async def test_download_size_mismatch_raises_and_cleans_up(osc_server, tmp_path): + client = OscHttpClient(host=f"127.0.0.1:{osc_server.port}", scheme="http") + target = tmp_path / "overhead.mp4" + with pytest.raises(OscDownloadError): + await client.download( + remote_path="/DCIM/Camera01/VID_FAKE.mp4", + local_path=target, + expected_size=99999, # wrong size triggers atomic failure + ) + assert not target.exists() + assert not (tmp_path / "overhead.mp4.part").exists() diff --git a/uv.lock b/uv.lock index 1246a32..4b68e78 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,166 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "yarl", version = "1.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" }, + { url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" }, + { url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" }, + { url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" }, + { url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" }, + { url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a5/630bc484695d4a1342bbae85fb8689bf979106525684fc88f05b397324ad/aiohttp-3.13.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:347542f0ea3f95b2a955ee6656461fa1c776e401ac50ebce055a6c38454a0adf", size = 752872, upload-time = "2026-03-31T22:00:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/6a19dda37fda94a9ebefb3c1ae0ff419ac7fbf4fb40750e992829fc13614/aiohttp-3.13.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:178c7b5e62b454c2bc790786e6058c3cc968613b4419251b478c153a4aec32b1", size = 504582, upload-time = "2026-03-31T22:00:18.191Z" }, + { url = "https://files.pythonhosted.org/packages/d5/34/8413eafee3421ade2d6ce9e7c0da1213e1d7f0049be09dcdc342b03a39ba/aiohttp-3.13.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af545c2cffdb0967a96b6249e6f5f7b0d92cdfd267f9d5238d5b9ca63e8edb10", size = 499094, upload-time = "2026-03-31T22:00:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/da/cf/c6f97006093d1e8ca40fbab843ff49ec7725ab668f0714dd1cb702c62cbd/aiohttp-3.13.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:206b7b3ef96e4ce211754f0cd003feb28b7d81f0ad26b8d077a5d5161436067f", size = 1669505, upload-time = "2026-03-31T22:00:24.01Z" }, + { url = "https://files.pythonhosted.org/packages/c2/27/3b2288e66dcec8b04771b2bee3909f70e4072bea995cde5ab7e775e73ddc/aiohttp-3.13.5-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ee5e86776273de1795947d17bddd6bb19e0365fd2af4289c0d2c5454b6b1d36b", size = 1648928, upload-time = "2026-03-31T22:00:27.001Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7f/605d766887594a88dcc27a19663499c7c5e13e7aa87f129b763765a2ee63/aiohttp-3.13.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95d14ca7abefde230f7639ec136ade282655431fd5db03c343b19dda72dd1643", size = 1731800, upload-time = "2026-03-31T22:00:29.603Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/5a878e728e30699d22b118f1a6ad576ab6fff9eb2c6fc8a7faa9376a1c3e/aiohttp-3.13.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:912d4b6af530ddb1338a66229dac3a25ff11d4448be3ec3d6340583995f56031", size = 1824247, upload-time = "2026-03-31T22:00:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/84b448291e9996bb83bf4fad3a71a9786d542f19c50a3ff0531bfaba6fac/aiohttp-3.13.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e999f0c88a458c836d5fb521814e92ed2172c649200336a6df514987c1488258", size = 1670742, upload-time = "2026-03-31T22:00:34.788Z" }, + { url = "https://files.pythonhosted.org/packages/14/a8/d8d5d1ab6d29a4a3bdb9db31f161e338bfdf6638f6574ea8380f1d4a243c/aiohttp-3.13.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39380e12bd1f2fdab4285b6e055ad48efbaed5c836433b142ed4f5b9be71036a", size = 1562474, upload-time = "2026-03-31T22:00:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/bd889697916f10b65524422c61b4eeaf919eb35a170290cccb680cbe4eb4/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9efcc0f11d850cefcafdd9275b9576ad3bfb539bed96807663b32ad99c4d4b88", size = 1642235, upload-time = "2026-03-31T22:00:40.541Z" }, + { url = "https://files.pythonhosted.org/packages/60/42/3f1928107131f1413a5972ace14ddcd5364968e9bd7b3ad71272defafc9c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:147b4f501d0292077f29d5268c16bb7c864a1f054d7001c4c1812c0421ea1ed0", size = 1655397, upload-time = "2026-03-31T22:00:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b2/79/c4bbcf4cac3a4715a326e49720ccdc3a4b5e14a367c5029eae7727d06029/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d147004fede1b12f6013a6dbb2a26a986a671a03c6ea740ddc76500e5f1c399f", size = 1703509, upload-time = "2026-03-31T22:00:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e6/32d245876f211a7308a7d5437707f9296b1f9837a2888a407ed04e61321c/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9277145d36a01653863899c665243871434694bcc3431922c3b35c978061bdb8", size = 1550098, upload-time = "2026-03-31T22:00:49.48Z" }, + { url = "https://files.pythonhosted.org/packages/db/62/ab0f1304def56ce2356e6fbb9f0b024d6544010351430070f48f53b89e0a/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4e704c52438f66fdd89588346183d898bb42167cf88f8b7ff1c0f9fc957c348f", size = 1724326, upload-time = "2026-03-31T22:00:52.165Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9a/aab4469689024046220ea438aa020ea2ae04cd1dd71aea3057e094f8c357/aiohttp-3.13.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8a4d3427e8de1312ddf309cc482186466c79895b3a139fed3259fc01dfa9a5b", size = 1658824, upload-time = "2026-03-31T22:00:55.122Z" }, + { url = "https://files.pythonhosted.org/packages/b0/98/bcc35d4db687acabf06d41f561a99fa88bca145292513388c858d99b72c5/aiohttp-3.13.5-cp39-cp39-win32.whl", hash = "sha256:6f497a6876aa4b1a102b04996ce4c1170c7040d83faa9387dd921c16e30d5c83", size = 440302, upload-time = "2026-03-31T22:00:57.673Z" }, + { url = "https://files.pythonhosted.org/packages/25/61/b0203c2ef6bd268fca0eda142f0efbba7cbebd7ad38f7bb01dd31c2ff68e/aiohttp-3.13.5-cp39-cp39-win_amd64.whl", hash = "sha256:cb979826071c0986a5f08333a36104153478ce6018c58cba7f9caddaf63d5d67", size = 463076, upload-time = "2026-03-31T22:01:00.264Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -69,6 +229,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "av" version = "15.1.0" @@ -533,6 +702,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/ae5cdac87a00962122ea37bb346d41b66aec05f9ce328fa2b9e216f8967b/frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", size = 86967, upload-time = "2025-10-06T05:37:55.607Z" }, + { url = "https://files.pythonhosted.org/packages/8a/10/17059b2db5a032fd9323c41c39e9d1f5f9d0c8f04d1e4e3e788573086e61/frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", size = 49984, upload-time = "2025-10-06T05:37:57.049Z" }, + { url = "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", size = 50240, upload-time = "2025-10-06T05:37:58.145Z" }, + { url = "https://files.pythonhosted.org/packages/4e/45/3dfb7767c2a67d123650122b62ce13c731b6c745bc14424eea67678b508c/frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", size = 219472, upload-time = "2025-10-06T05:37:59.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bf/5bf23d913a741b960d5c1dac7c1985d8a2a1d015772b2d18ea168b08e7ff/frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", size = 221531, upload-time = "2025-10-06T05:38:00.521Z" }, + { url = "https://files.pythonhosted.org/packages/d0/03/27ec393f3b55860859f4b74cdc8c2a4af3dbf3533305e8eacf48a4fd9a54/frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", size = 219211, upload-time = "2025-10-06T05:38:01.842Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ad/0fd00c404fa73fe9b169429e9a972d5ed807973c40ab6b3cf9365a33d360/frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", size = 231775, upload-time = "2025-10-06T05:38:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/86962566154cb4d2995358bc8331bfc4ea19d07db1a96f64935a1607f2b6/frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", size = 236631, upload-time = "2025-10-06T05:38:04.609Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/6ffad161dbd83782d2c66dc4d378a9103b31770cb1e67febf43aea42d202/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", size = 218632, upload-time = "2025-10-06T05:38:05.917Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/4677eee46e0a97f9b30735e6ad0bf6aba3e497986066eb68807ac85cf60f/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", size = 235967, upload-time = "2025-10-06T05:38:07.614Z" }, + { url = "https://files.pythonhosted.org/packages/05/f3/86e75f8639c5a93745ca7addbbc9de6af56aebb930d233512b17e46f6493/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", size = 228799, upload-time = "2025-10-06T05:38:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/30/00/39aad3a7f0d98f5eb1d99a3c311215674ed87061aecee7851974b335c050/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", size = 230566, upload-time = "2025-10-06T05:38:10.52Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4d/aa144cac44568d137846ddc4d5210fb5d9719eb1d7ec6fa2728a54b5b94a/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", size = 217715, upload-time = "2025-10-06T05:38:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/64/4c/8f665921667509d25a0dd72540513bc86b356c95541686f6442a3283019f/frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", size = 39933, upload-time = "2025-10-06T05:38:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/79/bd/bcc926f87027fad5e59926ff12d136e1082a115025d33c032d1cd69ab377/frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", size = 44121, upload-time = "2025-10-06T05:38:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/4c/07/9c2e4eb7584af4b705237b971b89a4155a8e57599c4483a131a39256a9a0/frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", size = 40312, upload-time = "2025-10-06T05:38:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -664,6 +970,162 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/74525ebe3eb5fddcd6735fc03cbea3feeed4122b53bc798ac32d297ac9ae/multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f", size = 77107, upload-time = "2026-01-26T02:46:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9a/ce8744e777a74b3050b1bf56be3eed1053b3457302ea055f1ea437200a23/multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358", size = 44943, upload-time = "2026-01-26T02:46:14.016Z" }, + { url = "https://files.pythonhosted.org/packages/83/9c/1d2a283d9c6f31e260cb6c2fccadc3edcf6c4c14ee0929cd2af4d2606dd7/multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5", size = 44603, upload-time = "2026-01-26T02:46:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/87/9d/3b186201671583d8e8d6d79c07481a5aafd0ba7575e3d8566baec80c1e82/multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0", size = 240573, upload-time = "2026-01-26T02:46:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a52f5d4d0754311d1ac78478e34dff88de71259a8585e05ee14e5f877caf/multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8", size = 240106, upload-time = "2026-01-26T02:46:18.432Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/d80118e6c30ff55b7d171bdc5520aad4b9626e657520b8d7c8ca8c2fad12/multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0", size = 219418, upload-time = "2026-01-26T02:46:20.526Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/896e60b3457f194de77c7de64f9acce9f75da0518a5230ce1df534f6747b/multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f", size = 252124, upload-time = "2026-01-26T02:46:22.157Z" }, + { url = "https://files.pythonhosted.org/packages/f4/de/ba6b30447c36a37078d0ba604aa12c1a52887af0c355236ca6e0a9d5286f/multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f", size = 249402, upload-time = "2026-01-26T02:46:23.718Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b2/50a383c96230e432895a2fd3bcfe1b65785899598259d871d5de6b93180c/multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e", size = 240346, upload-time = "2026-01-26T02:46:25.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/37/16d391fd8da544b1489306e38a46785fa41dd0f0ef766837ed7d4676dde0/multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2", size = 237010, upload-time = "2026-01-26T02:46:27.408Z" }, + { url = "https://files.pythonhosted.org/packages/b0/24/3152ee026eda86d5d3e3685182911e6951af7a016579da931080ce6ac9ad/multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8", size = 232018, upload-time = "2026-01-26T02:46:29.941Z" }, + { url = "https://files.pythonhosted.org/packages/9c/1f/48d3c27a72be7fd23a55d8847193c459959bf35a5bb5844530dab00b739b/multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941", size = 241498, upload-time = "2026-01-26T02:46:32.052Z" }, + { url = "https://files.pythonhosted.org/packages/1a/45/413643ae2952d0decdf6c1250f86d08a43e143271441e81027e38d598bd7/multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a", size = 247957, upload-time = "2026-01-26T02:46:33.666Z" }, + { url = "https://files.pythonhosted.org/packages/50/f8/f1d0ac23df15e0470776388bdb261506f63af1f81d28bacb5e262d6e12b6/multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de", size = 241651, upload-time = "2026-01-26T02:46:35.7Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c9/1a2a18f383cf129add66b6c36b75c3911a7ba95cf26cb141482de085cc12/multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5", size = 236371, upload-time = "2026-01-26T02:46:37.37Z" }, + { url = "https://files.pythonhosted.org/packages/bb/aa/77d87e3fca31325b87e0eb72d5fe9a7472dcb51391a42df7ac1f3842f6c0/multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0", size = 41426, upload-time = "2026-01-26T02:46:39.026Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b3/e8863e6a2da15a9d7e98976ff402e871b7352c76566df6c18d0378e0d9cf/multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4", size = 46180, upload-time = "2026-01-26T02:46:40.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/d3/dd4fa951ad5b5fa216bf30054d705683d13405eea7459833d78f31b74c9c/multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9", size = 43231, upload-time = "2026-01-26T02:46:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "numpy" version = "2.0.2" @@ -1102,6 +1564,135 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/0ebaec9003f5d619a7475165961f8e3083cf8644d704b60395df3601632d/propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", size = 80277, upload-time = "2025-10-08T19:48:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/34/58/04af97ac586b4ef6b9026c3fd36ee7798b737a832f5d3440a4280dcebd3a/propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", size = 45865, upload-time = "2025-10-08T19:48:37.859Z" }, + { url = "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", size = 47636, upload-time = "2025-10-08T19:48:39.038Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/317048c6d91c356c7154dca5af019e6effeb7ee15fa6a6db327cc19e12b4/propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", size = 201126, upload-time = "2025-10-08T19:48:40.774Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/0b2a7a5a6ee83292b4b997dbd80549d8ce7d40b6397c1646c0d9495f5a85/propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", size = 209837, upload-time = "2025-10-08T19:48:42.167Z" }, + { url = "https://files.pythonhosted.org/packages/a5/92/c699ac495a6698df6e497fc2de27af4b6ace10d8e76528357ce153722e45/propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", size = 215578, upload-time = "2025-10-08T19:48:43.56Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ee/14de81c5eb02c0ee4f500b4e39c4e1bd0677c06e72379e6ab18923c773fc/propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", size = 197187, upload-time = "2025-10-08T19:48:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/48dce9aaa6d8dd5a0859bad75158ec522546d4ac23f8e2f05fac469477dd/propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", size = 193478, upload-time = "2025-10-08T19:48:47.743Z" }, + { url = "https://files.pythonhosted.org/packages/60/b5/0516b563e801e1ace212afde869a0596a0d7115eec0b12d296d75633fb29/propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", size = 190650, upload-time = "2025-10-08T19:48:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/24/89/e0f7d4a5978cd56f8cd67735f74052f257dc471ec901694e430f0d1572fe/propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", size = 200251, upload-time = "2025-10-08T19:48:51.4Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/a1fac863d473876ed4406c914f2e14aa82d2f10dd207c9e16fc383cc5a24/propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781", size = 200919, upload-time = "2025-10-08T19:48:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4e/f86a256ff24944cf5743e4e6c6994e3526f6acfcfb55e21694c2424f758c/propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", size = 193211, upload-time = "2025-10-08T19:48:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3f/3fbad5f4356b068f1b047d300a6ff2c66614d7030f078cd50be3fec04228/propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", size = 38314, upload-time = "2025-10-08T19:48:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/a4/45/d78d136c3a3d215677abb886785aae744da2c3005bcb99e58640c56529b1/propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", size = 41912, upload-time = "2025-10-08T19:48:57.995Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/b0632941f25139f4e58450b307242951f7c2717a5704977c6d5323a800af/propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", size = 38450, upload-time = "2025-10-08T19:48:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1499,6 +2090,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-aiohttp" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, +] + [[package]] name = "pytest-asyncio" version = "1.2.0" @@ -1788,6 +2395,7 @@ viewer = [ dev = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-aiohttp" }, { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-mock" }, @@ -1827,6 +2435,7 @@ provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "multihost", "all"] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-aiohttp", specifier = ">=1.1.0" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-mock", specifier = ">=3.12.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, @@ -2547,6 +3156,295 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/13/cc2cda5a998efb894e90f96b8e1320098924ed331540122049ddab31d5c8/winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d", size = 128967, upload-time = "2025-06-06T14:02:09.698Z" }, ] +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "multidict", marker = "python_full_version < '3.10'" }, + { name = "propcache", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/94/fd/6480106702a79bcceda5fd9c63cb19a04a6506bd5ce7fd8d9b63742f0021/yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", size = 141301, upload-time = "2025-10-06T14:12:19.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/e1/6d95d21b17a93e793e4ec420a925fe1f6a9342338ca7a563ed21129c0990/yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", size = 93864, upload-time = "2025-10-06T14:12:21.05Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", size = 94706, upload-time = "2025-10-06T14:12:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/d7bfbc28a88c2895ecd0da6a874def0c147de78afc52c773c28e1aa233a3/yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", size = 347100, upload-time = "2025-10-06T14:12:28.527Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e8/37a1e7b99721c0564b1fc7b0a4d1f595ef6fb8060d82ca61775b644185f7/yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", size = 318902, upload-time = "2025-10-06T14:12:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/34724449d7ef2db4f22df644f2dac0b8a275d20f585e526937b3ae47b02d/yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", size = 363302, upload-time = "2025-10-06T14:12:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/88a39a5dad39889f192cce8d66cc4c58dbeca983e83f9b6bf23822a7ed91/yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", size = 370816, upload-time = "2025-10-06T14:12:34.01Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1f/5e895e547129413f56c76be2c3ce4b96c797d2d0ff3e16a817d9269b12e6/yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", size = 346465, upload-time = "2025-10-06T14:12:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/11/13/a750e9fd6f9cc9ed3a52a70fe58ffe505322f0efe0d48e1fd9ffe53281f5/yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", size = 341506, upload-time = "2025-10-06T14:12:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/67/bb6024de76e7186611ebe626aec5b71a2d2ecf9453e795f2dbd80614784c/yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", size = 335030, upload-time = "2025-10-06T14:12:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/50b38447fd94a7992996a62b8b463d0579323fcfc08c61bdba949eef8a5d/yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", size = 358560, upload-time = "2025-10-06T14:12:41.547Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/c020b6f547578c4e3dbb6335bf918f26e2f34ad0d1e515d72fd33ac0c635/yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", size = 357290, upload-time = "2025-10-06T14:12:43.861Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/c49a619ee35a402fa3a7019a4fa8d26878fec0d1243f6968bbf516789578/yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", size = 350700, upload-time = "2025-10-06T14:12:46.868Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c9/f5042d87777bf6968435f04a2bbb15466b2f142e6e47fa4f34d1a3f32f0c/yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", size = 82323, upload-time = "2025-10-06T14:12:48.633Z" }, + { url = "https://files.pythonhosted.org/packages/fd/58/d00f7cad9eba20c4eefac2682f34661d1d1b3a942fc0092eb60e78cfb733/yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", size = 87145, upload-time = "2025-10-06T14:12:50.241Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/70904f365080780d38b919edd42d224b8c4ce224a86950d2eaa2a24366ad/yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", size = 82173, upload-time = "2025-10-06T14:12:51.869Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "propcache", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" }, + { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" }, + { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + [[package]] name = "zeroconf" version = "0.148.0" From 4b3bce153a6292d8461f11652039628a5958db5b Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:45:12 -0700 Subject: [PATCH 06/28] feat(go3s/wifi): add cross-platform WiFi switcher Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/insta360_go3s/wifi/switcher.py | 139 ++++++++++++++++++ .../insta360_go3s/test_wifi_switcher.py | 122 +++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/wifi/switcher.py create mode 100644 tests/unit/adapters/insta360_go3s/test_wifi_switcher.py diff --git a/src/syncfield/adapters/insta360_go3s/wifi/switcher.py b/src/syncfield/adapters/insta360_go3s/wifi/switcher.py new file mode 100644 index 0000000..a33153e --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/wifi/switcher.py @@ -0,0 +1,139 @@ +"""Cross-platform WiFi network switching for Insta360 Go3S aggregation. + +Each :class:`WifiSwitcher` subclass owns one OS-native CLI for switching +the host's primary WiFi interface between the user's lab network and +the camera's AP. The factory :func:`wifi_switcher_for_platform` returns +the right subclass based on ``sys.platform``. +""" +from __future__ import annotations + +import abc +import shutil +import subprocess +import sys +from typing import Optional + + +class WifiSwitcherError(RuntimeError): + """Raised when a WiFi switch / restore step cannot be completed.""" + + +class WifiSwitcher(abc.ABC): + def __init__(self, *, interface: str): + self.interface = interface + + @abc.abstractmethod + def current_ssid(self) -> Optional[str]: ... + + @abc.abstractmethod + def connect(self, ssid: str, password: str) -> None: ... + + def restore(self, prev_ssid: Optional[str], prev_password: Optional[str] = None) -> None: + """Default restore: reconnect to ``prev_ssid`` if it's known. + + ``prev_password`` is rarely required (the OS keychain usually + remembers it) but supported for completeness. + """ + if prev_ssid is None: + return + self.connect(prev_ssid, prev_password or "") + + +# ----- macOS ----- + +class MacWifiSwitcher(WifiSwitcher): + def current_ssid(self) -> Optional[str]: + result = subprocess.run( + ["networksetup", "-getairportnetwork", self.interface], + capture_output=True, + text=True, + check=False, + ) + line = (result.stdout or "").strip() + prefix = "Current Wi-Fi Network: " + if line.startswith(prefix): + return line[len(prefix):].strip() or None + return None + + def connect(self, ssid: str, password: str) -> None: + result = subprocess.run( + ["networksetup", "-setairportnetwork", self.interface, ssid, password], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 or "Could not" in (result.stdout or ""): + raise WifiSwitcherError( + f"networksetup failed: rc={result.returncode} stdout={result.stdout!r} stderr={result.stderr!r}" + ) + + +# ----- Linux ----- + +class LinuxWifiSwitcher(WifiSwitcher): + def current_ssid(self) -> Optional[str]: + # Prefer iwgetid which is universally available; nmcli works too. + if shutil.which("iwgetid"): + r = subprocess.run( + ["iwgetid", self.interface, "--raw"], + capture_output=True, + text=True, + check=False, + ) + ssid = (r.stdout or "").strip() + return ssid or None + # Fallback to nmcli + r = subprocess.run( + ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], + capture_output=True, + text=True, + check=False, + ) + for line in (r.stdout or "").splitlines(): + if line.startswith("yes:"): + return line.split(":", 1)[1] or None + return None + + def connect(self, ssid: str, password: str) -> None: + result = subprocess.run( + [ + "nmcli", "device", "wifi", "connect", ssid, + "password", password, + "ifname", self.interface, + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise WifiSwitcherError( + f"nmcli failed: rc={result.returncode} stderr={result.stderr!r}" + ) + + +# ----- Windows (stub) ----- + +class WindowsWifiSwitcher(WifiSwitcher): + def current_ssid(self) -> Optional[str]: + raise NotImplementedError( + "Windows WiFi switching is not supported in v1; " + "use BLE-only mode or run on macOS/Linux." + ) + + def connect(self, ssid: str, password: str) -> None: + raise NotImplementedError( + "Windows WiFi switching is not supported in v1; " + "use BLE-only mode or run on macOS/Linux." + ) + + +# ----- Factory ----- + +def wifi_switcher_for_platform(*, interface: Optional[str] = None) -> WifiSwitcher: + if sys.platform == "darwin": + return MacWifiSwitcher(interface=interface or "en0") + if sys.platform.startswith("linux"): + return LinuxWifiSwitcher(interface=interface or "wlan0") + if sys.platform.startswith("win"): + return WindowsWifiSwitcher(interface=interface or "Wi-Fi") + raise WifiSwitcherError(f"Unsupported platform: {sys.platform}") diff --git a/tests/unit/adapters/insta360_go3s/test_wifi_switcher.py b/tests/unit/adapters/insta360_go3s/test_wifi_switcher.py new file mode 100644 index 0000000..53e206c --- /dev/null +++ b/tests/unit/adapters/insta360_go3s/test_wifi_switcher.py @@ -0,0 +1,122 @@ +import subprocess +from unittest.mock import MagicMock, call, patch + +import pytest + +from syncfield.adapters.insta360_go3s.wifi.switcher import ( + LinuxWifiSwitcher, + MacWifiSwitcher, + WifiSwitcher, + WifiSwitcherError, + WindowsWifiSwitcher, + wifi_switcher_for_platform, +) + + +def test_abc_cannot_be_instantiated_directly(): + with pytest.raises(TypeError): + WifiSwitcher() # type: ignore[abstract] + + +# ----- macOS ----- + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.subprocess.run") +def test_mac_current_ssid_parses_networksetup_output(mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="Current Wi-Fi Network: LabWiFi\n", + stderr="", + ) + sw = MacWifiSwitcher(interface="en0") + assert sw.current_ssid() == "LabWiFi" + + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.subprocess.run") +def test_mac_current_ssid_returns_none_when_disconnected(mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout="You are not associated with an AirPort network.\n", + stderr="", + ) + sw = MacWifiSwitcher(interface="en0") + assert sw.current_ssid() is None + + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.subprocess.run") +def test_mac_connect_invokes_setairportnetwork(mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + sw = MacWifiSwitcher(interface="en0") + sw.connect("Go3S-CAFEBABE.OSC", "88888888") + cmd = mock_run.call_args.args[0] + assert cmd[0] == "networksetup" + assert "-setairportnetwork" in cmd + assert "en0" in cmd + assert "Go3S-CAFEBABE.OSC" in cmd + assert "88888888" in cmd + + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.subprocess.run") +def test_mac_connect_failure_raises(mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="Could not find network" + ) + sw = MacWifiSwitcher(interface="en0") + with pytest.raises(WifiSwitcherError): + sw.connect("does-not-exist", "x") + + +# ----- Linux ----- + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.shutil.which", return_value="/usr/bin/iwgetid") +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.subprocess.run") +def test_linux_current_ssid_parses_iwgetid(mock_run, mock_which): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="LabWiFi\n", stderr="" + ) + sw = LinuxWifiSwitcher(interface="wlan0") + assert sw.current_ssid() == "LabWiFi" + + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.subprocess.run") +def test_linux_connect_invokes_nmcli(mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + sw = LinuxWifiSwitcher(interface="wlan0") + sw.connect("Go3S-CAFEBABE.OSC", "88888888") + cmd = mock_run.call_args.args[0] + assert cmd[0] == "nmcli" + assert "wlan0" in cmd + assert "Go3S-CAFEBABE.OSC" in cmd + assert "88888888" in cmd + + +# ----- Windows stub ----- + +def test_windows_raises_not_implemented(): + sw = WindowsWifiSwitcher(interface="Wi-Fi") + with pytest.raises(NotImplementedError): + sw.connect("x", "y") + + +# ----- Factory ----- + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.sys.platform", "darwin") +def test_factory_returns_mac_on_darwin(): + sw = wifi_switcher_for_platform() + assert isinstance(sw, MacWifiSwitcher) + + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.sys.platform", "linux") +def test_factory_returns_linux_on_linux(): + sw = wifi_switcher_for_platform() + assert isinstance(sw, LinuxWifiSwitcher) + + +@patch("syncfield.adapters.insta360_go3s.wifi.switcher.sys.platform", "win32") +def test_factory_returns_windows_on_win32(): + sw = wifi_switcher_for_platform() + assert isinstance(sw, WindowsWifiSwitcher) From 90a45163c14a491b4afc4e3419af98db72774453 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:46:53 -0700 Subject: [PATCH 07/28] feat(go3s/aggregation): add job + progress types --- .../insta360_go3s/aggregation/types.py | 105 ++++++++++++++++++ .../insta360_go3s/test_aggregation_types.py | 71 ++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/aggregation/types.py create mode 100644 tests/unit/adapters/insta360_go3s/test_aggregation_types.py diff --git a/src/syncfield/adapters/insta360_go3s/aggregation/types.py b/src/syncfield/adapters/insta360_go3s/aggregation/types.py new file mode 100644 index 0000000..cc940ca --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/aggregation/types.py @@ -0,0 +1,105 @@ +"""Data types for the Insta360 Go3S aggregation queue.""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Optional + + +class AggregationState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass +class AggregationCameraSpec: + stream_id: str + ble_address: str + wifi_ssid: str + wifi_password: str + sd_path: str + local_filename: str + size_bytes: int + done: bool = False + error: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AggregationCameraSpec": + return cls(**data) + + +@dataclass +class AggregationJob: + job_id: str + episode_id: str + episode_dir: Path + cameras: list[AggregationCameraSpec] + state: AggregationState = AggregationState.PENDING + started_at_ns: Optional[int] = None + completed_at_ns: Optional[int] = None + error: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "episode_id": self.episode_id, + "episode_dir": str(self.episode_dir), + "cameras": [c.to_dict() for c in self.cameras], + "state": self.state.value, + "started_at_ns": self.started_at_ns, + "completed_at_ns": self.completed_at_ns, + "error": self.error, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AggregationJob": + return cls( + job_id=data["job_id"], + episode_id=data["episode_id"], + episode_dir=Path(data["episode_dir"]), + cameras=[AggregationCameraSpec.from_dict(c) for c in data["cameras"]], + state=AggregationState(data["state"]), + started_at_ns=data.get("started_at_ns"), + completed_at_ns=data.get("completed_at_ns"), + error=data.get("error"), + ) + + def manifest_path(self) -> Path: + return self.episode_dir / "aggregation.json" + + def write_manifest(self) -> None: + self.manifest_path().parent.mkdir(parents=True, exist_ok=True) + self.manifest_path().write_text(json.dumps(self.to_dict(), indent=2)) + + +@dataclass +class AggregationProgress: + job_id: str + episode_id: str + state: AggregationState + cameras_total: int + cameras_done: int + current_stream_id: Optional[str] = None + current_bytes: int = 0 + current_total_bytes: int = 0 + error: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "episode_id": self.episode_id, + "state": self.state.value, + "cameras_total": self.cameras_total, + "cameras_done": self.cameras_done, + "current_stream_id": self.current_stream_id, + "current_bytes": self.current_bytes, + "current_total_bytes": self.current_total_bytes, + "error": self.error, + } diff --git a/tests/unit/adapters/insta360_go3s/test_aggregation_types.py b/tests/unit/adapters/insta360_go3s/test_aggregation_types.py new file mode 100644 index 0000000..aebc7b9 --- /dev/null +++ b/tests/unit/adapters/insta360_go3s/test_aggregation_types.py @@ -0,0 +1,71 @@ +from pathlib import Path + +from syncfield.adapters.insta360_go3s.aggregation.types import ( + AggregationCameraSpec, + AggregationJob, + AggregationProgress, + AggregationState, +) + + +def test_state_values(): + assert AggregationState.PENDING.value == "pending" + assert AggregationState.RUNNING.value == "running" + assert AggregationState.COMPLETED.value == "completed" + assert AggregationState.FAILED.value == "failed" + + +def test_camera_spec_round_trips_dict(): + spec = AggregationCameraSpec( + stream_id="overhead", + ble_address="AA:BB", + wifi_ssid="Go3S-CAFEBABE.OSC", + wifi_password="88888888", + sd_path="/DCIM/Camera01/VID_FAKE.mp4", + local_filename="overhead.mp4", + size_bytes=12, + done=False, + ) + d = spec.to_dict() + restored = AggregationCameraSpec.from_dict(d) + assert restored == spec + + +def test_job_to_dict_includes_all_cameras(tmp_path): + job = AggregationJob( + job_id="agg_x", + episode_id="ep_x", + episode_dir=tmp_path, + cameras=[ + AggregationCameraSpec( + stream_id="overhead", + ble_address="AA:BB", + wifi_ssid="Go3S-X.OSC", + wifi_password="88888888", + sd_path="/DCIM/Camera01/VID.mp4", + local_filename="overhead.mp4", + size_bytes=0, + done=False, + ) + ], + state=AggregationState.PENDING, + ) + d = job.to_dict() + assert d["job_id"] == "agg_x" + assert d["episode_id"] == "ep_x" + assert len(d["cameras"]) == 1 + assert d["state"] == "pending" + + +def test_progress_dataclass_defaults(): + p = AggregationProgress( + job_id="agg_x", + episode_id="ep_x", + state=AggregationState.RUNNING, + cameras_total=2, + cameras_done=0, + ) + assert p.current_stream_id is None + assert p.current_bytes == 0 + assert p.current_total_bytes == 0 + assert p.error is None From 676cc077be8e2848a5646ec7e6f629ecb3c08f11 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 02:49:09 -0700 Subject: [PATCH 08/28] feat(go3s/aggregation): add background queue worker with retry Co-Authored-By: Claude Sonnet 4.6 --- .../insta360_go3s/aggregation/queue.py | 251 ++++++++++++++++++ .../insta360_go3s/test_aggregation_queue.py | 161 +++++++++++ 2 files changed, 412 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/aggregation/queue.py create mode 100644 tests/unit/adapters/insta360_go3s/test_aggregation_queue.py diff --git a/src/syncfield/adapters/insta360_go3s/aggregation/queue.py b/src/syncfield/adapters/insta360_go3s/aggregation/queue.py new file mode 100644 index 0000000..1930b11 --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/aggregation/queue.py @@ -0,0 +1,251 @@ +"""Background aggregation queue for Insta360 Go3S episodes. + +A single asyncio worker processes :class:`AggregationJob`s in FIFO order. +Per-camera atomicity: a failed download leaves no partial files for that +camera. Per-episode atomicity: a job is COMPLETED only when every camera +succeeds; otherwise FAILED with per-camera breakdown for selective retry. +""" +from __future__ import annotations + +import abc +import asyncio +import json +import logging +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from .types import ( + AggregationCameraSpec, + AggregationJob, + AggregationProgress, + AggregationState, +) + +_log = logging.getLogger(__name__) + +ProgressListener = Callable[[AggregationProgress], None] +ChunkCallback = Callable[[str, int, int], None] # (stream_id, done, total) + + +class AggregationDownloader(abc.ABC): + """Pluggable backend that performs the WiFi switch + OSC download for one camera.""" + + @abc.abstractmethod + async def run( + self, + camera: AggregationCameraSpec, + target_dir: Path, + on_chunk: ChunkCallback, + ) -> None: ... + + +@dataclass +class _JobHandle: + job: AggregationJob + done: asyncio.Event + final_progress: Optional[AggregationProgress] = None + + async def wait(self) -> AggregationProgress: + await self.done.wait() + if self.final_progress is None: + raise RuntimeError( + f"AggregationJob {self.job.job_id}: done event set but final_progress unpopulated" + ) + return self.final_progress + + +class AggregationQueue: + def __init__(self, *, downloader: AggregationDownloader): + self._downloader = downloader + self._queue: asyncio.Queue[Optional[_JobHandle]] = asyncio.Queue() + self._handles: dict[str, _JobHandle] = {} + self._listeners: list[ProgressListener] = [] + self._worker_task: Optional[asyncio.Task] = None # type: ignore[type-arg] + self._stop = asyncio.Event() + + async def start(self) -> None: + if self._worker_task is None or self._worker_task.done(): + self._stop.clear() + self._worker_task = asyncio.create_task(self._worker_loop()) + + async def shutdown(self) -> None: + self._stop.set() + await self._queue.put(None) + if self._worker_task is not None: + await self._worker_task + self._worker_task = None + + def subscribe(self, listener: ProgressListener) -> None: + self._listeners.append(listener) + + def unsubscribe(self, listener: ProgressListener) -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + def enqueue(self, job: AggregationJob) -> _JobHandle: + handle = _JobHandle(job=job, done=asyncio.Event()) + self._handles[job.job_id] = handle + job.write_manifest() + self._queue.put_nowait(handle) + return handle + + def retry(self, job_id: str) -> _JobHandle: + handle = self._handles.get(job_id) + if handle is None: + raise KeyError(job_id) + handle.job.state = AggregationState.PENDING + handle.job.error = None + handle.done = asyncio.Event() + handle.final_progress = None + handle.job.write_manifest() + self._queue.put_nowait(handle) + return handle + + def status(self, job_id: str) -> Optional[AggregationProgress]: + handle = self._handles.get(job_id) + return handle.final_progress if handle else None + + def recover_from_disk(self, *, search_root: Path) -> list[AggregationJob]: + recovered: list[AggregationJob] = [] + for manifest in search_root.rglob("aggregation.json"): + try: + data = json.loads(manifest.read_text()) + job = AggregationJob.from_dict(data) + except Exception: + continue + if job.state in (AggregationState.PENDING, AggregationState.RUNNING): + job.state = AggregationState.PENDING + recovered.append(job) + return recovered + + async def _worker_loop(self) -> None: + while not self._stop.is_set(): + handle = await self._queue.get() + if handle is None: + break + if self._stop.is_set(): + # Shutdown raced with queue.get(): fail this handle via the drain path. + self._fail_queued_handle(handle) + break + try: + await self._run_job(handle) + except Exception as e: + handle.job.state = AggregationState.FAILED + handle.job.error = f"worker crash: {e}" + handle.job.write_manifest() + final = self._snapshot(handle.job) + handle.final_progress = final + self._notify(final) + handle.done.set() + # Yield to the loop so a pending shutdown() caller can observe + # the completion and set _stop before we pick up the next job. + await asyncio.sleep(0) + # Drain any remaining queued jobs so their waiters don't hang + while not self._queue.empty(): + try: + handle = self._queue.get_nowait() + except asyncio.QueueEmpty: + break + if handle is None: + continue + self._fail_queued_handle(handle) + + def _fail_queued_handle(self, handle: _JobHandle) -> None: + handle.job.state = AggregationState.FAILED + handle.job.error = "queue shut down before job started" + handle.job.write_manifest() + handle.final_progress = self._snapshot(handle.job) + self._notify(handle.final_progress) + handle.done.set() + + async def _run_job(self, handle: _JobHandle) -> None: + job = handle.job + job.state = AggregationState.RUNNING + job.started_at_ns = time.monotonic_ns() + job.error = None + job.write_manifest() + self._notify(self._snapshot(job)) + + any_failure = False + for camera in job.cameras: + if camera.done: + continue + current = AggregationProgress( + job_id=job.job_id, + episode_id=job.episode_id, + state=AggregationState.RUNNING, + cameras_total=len(job.cameras), + cameras_done=sum(1 for c in job.cameras if c.done), + current_stream_id=camera.stream_id, + current_bytes=0, + current_total_bytes=camera.size_bytes, + ) + self._notify(current) + + def chunk_cb(stream_id: str, done: int, total: int, *, _cam=camera) -> None: + p = AggregationProgress( + job_id=job.job_id, + episode_id=job.episode_id, + state=AggregationState.RUNNING, + cameras_total=len(job.cameras), + cameras_done=sum(1 for c in job.cameras if c.done), + current_stream_id=_cam.stream_id, + current_bytes=done, + current_total_bytes=total, + ) + self._notify(p) + + try: + await self._downloader.run(camera, job.episode_dir, chunk_cb) + camera.done = True + camera.error = None + except Exception as e: + camera.done = False + camera.error = str(e) + any_failure = True + job.write_manifest() + + job.completed_at_ns = time.monotonic_ns() + if any_failure: + job.state = AggregationState.FAILED + failed_ids = [c.stream_id for c in job.cameras if not c.done] + job.error = f"failed cameras: {failed_ids}" + else: + job.state = AggregationState.COMPLETED + job.error = None + job.write_manifest() + + final = self._snapshot(job) + handle.final_progress = final + self._notify(final) + handle.done.set() + + def _snapshot(self, job: AggregationJob) -> AggregationProgress: + return AggregationProgress( + job_id=job.job_id, + episode_id=job.episode_id, + state=job.state, + cameras_total=len(job.cameras), + cameras_done=sum(1 for c in job.cameras if c.done), + current_stream_id=None, + current_bytes=0, + current_total_bytes=0, + error=job.error, + ) + + def _notify(self, progress: AggregationProgress) -> None: + for listener in list(self._listeners): + try: + listener(progress) + except Exception: + # Do not let a buggy listener take down the worker. + _log.warning( + "AggregationQueue listener raised", exc_info=True + ) + + +def make_job_id() -> str: + return f"agg_{uuid.uuid4().hex[:12]}" diff --git a/tests/unit/adapters/insta360_go3s/test_aggregation_queue.py b/tests/unit/adapters/insta360_go3s/test_aggregation_queue.py new file mode 100644 index 0000000..bf81153 --- /dev/null +++ b/tests/unit/adapters/insta360_go3s/test_aggregation_queue.py @@ -0,0 +1,161 @@ +import asyncio +from pathlib import Path +from typing import Any + +import pytest + +from syncfield.adapters.insta360_go3s.aggregation.queue import ( + AggregationDownloader, + AggregationQueue, +) +from syncfield.adapters.insta360_go3s.aggregation.types import ( + AggregationCameraSpec, + AggregationJob, + AggregationProgress, + AggregationState, +) + + +class FakeDownloader(AggregationDownloader): + """Test double that simulates WiFi switch + OSC download.""" + + def __init__(self, *, fail_on: set[str] | None = None): + self.fail_on = fail_on or set() + self.actions: list[str] = [] + + async def run(self, camera: AggregationCameraSpec, target_dir: Path, + on_chunk: Any) -> None: + self.actions.append(f"download:{camera.stream_id}") + if camera.stream_id in self.fail_on: + raise RuntimeError(f"injected failure for {camera.stream_id}") + # Simulate two progress chunks then a completed file + on_chunk(camera.stream_id, 6, camera.size_bytes) + on_chunk(camera.stream_id, 12, camera.size_bytes) + target = target_dir / camera.local_filename + target.write_bytes(b"x" * 12) + + +def _make_job(tmp_path: Path, *stream_ids: str, size: int = 12) -> AggregationJob: + return AggregationJob( + job_id=f"job_{'_'.join(stream_ids)}", + episode_id="ep_x", + episode_dir=tmp_path, + cameras=[ + AggregationCameraSpec( + stream_id=sid, + ble_address=f"AA:{sid}", + wifi_ssid=f"Go3S-{sid}.OSC", + wifi_password="88888888", + sd_path=f"/DCIM/Camera01/{sid}.mp4", + local_filename=f"{sid}.mp4", + size_bytes=size, + ) + for sid in stream_ids + ], + ) + + +@pytest.mark.asyncio +async def test_enqueue_runs_to_completion(tmp_path): + downloader = FakeDownloader() + progress_log: list[AggregationProgress] = [] + q = AggregationQueue(downloader=downloader) + q.subscribe(lambda p: progress_log.append(p)) + await q.start() + + job = _make_job(tmp_path, "cam_a", "cam_b") + handle = q.enqueue(job) + final = await handle.wait() + + assert final.state == AggregationState.COMPLETED + assert final.cameras_done == 2 + assert (tmp_path / "cam_a.mp4").exists() + assert (tmp_path / "cam_b.mp4").exists() + assert any(p.state == AggregationState.RUNNING for p in progress_log) + assert progress_log[-1].state == AggregationState.COMPLETED + await q.shutdown() + + +@pytest.mark.asyncio +async def test_failure_marks_job_failed_and_preserves_other_files(tmp_path): + downloader = FakeDownloader(fail_on={"cam_b"}) + q = AggregationQueue(downloader=downloader) + await q.start() + job = _make_job(tmp_path, "cam_a", "cam_b") + handle = q.enqueue(job) + final = await handle.wait() + assert final.state == AggregationState.FAILED + assert "cam_b" in (final.error or "") + # cam_a should have completed + assert (tmp_path / "cam_a.mp4").exists() + await q.shutdown() + + +@pytest.mark.asyncio +async def test_retry_re_runs_only_failed_cameras(tmp_path): + downloader = FakeDownloader(fail_on={"cam_b"}) + q = AggregationQueue(downloader=downloader) + await q.start() + job = _make_job(tmp_path, "cam_a", "cam_b") + handle = q.enqueue(job) + await handle.wait() + + # Heal the downloader and retry + downloader.fail_on = set() + downloader.actions.clear() + handle2 = q.retry(job.job_id) + final = await handle2.wait() + assert final.state == AggregationState.COMPLETED + # Only cam_b should have been re-downloaded + assert downloader.actions == ["download:cam_b"] + await q.shutdown() + + +@pytest.mark.asyncio +async def test_recover_pending_jobs_from_disk(tmp_path): + job = _make_job(tmp_path, "cam_a") + job.write_manifest() + + downloader = FakeDownloader() + q = AggregationQueue(downloader=downloader) + recovered = q.recover_from_disk(search_root=tmp_path.parent) + assert any(j.job_id == job.job_id for j in recovered) + await q.start() + handle = q.enqueue(recovered[0]) + final = await handle.wait() + assert final.state == AggregationState.COMPLETED + await q.shutdown() + + +@pytest.mark.asyncio +async def test_shutdown_drains_queued_jobs_so_waiters_do_not_hang(tmp_path): + """Jobs queued but not yet started should not leave waiters hanging on shutdown.""" + + started = asyncio.Event() + may_finish = asyncio.Event() + + class SlowDownloader(AggregationDownloader): + async def run(self, camera, target_dir, on_chunk): + started.set() + await may_finish.wait() + (target_dir / camera.local_filename).write_bytes(b"x" * 12) + + q = AggregationQueue(downloader=SlowDownloader()) + await q.start() + + # First job will block on may_finish; second job sits in the queue + job1 = _make_job(tmp_path / "ep1", "cam_a") + job2 = _make_job(tmp_path / "ep2", "cam_b") + handle1 = q.enqueue(job1) + handle2 = q.enqueue(job2) + + await asyncio.wait_for(started.wait(), timeout=1.0) + # Now release the in-flight job, then shut down before job2 starts + may_finish.set() + await asyncio.wait_for(handle1.wait(), timeout=2.0) + + # Shut down — handle2 must NOT hang + await q.shutdown() + final2 = await asyncio.wait_for(handle2.wait(), timeout=1.0) + assert final2.state == AggregationState.FAILED + assert "shut down" in (final2.error or "") From 14225a6d84d6f379be8c3016f8f134c403610667 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:02:30 -0700 Subject: [PATCH 09/28] feat(go3s/aggregation): production downloader with WiFi + OSC Co-Authored-By: Claude Sonnet 4.6 --- .../insta360_go3s/aggregation/queue.py | 73 +++++++++++- .../insta360_go3s/test_aggregation_queue.py | 106 ++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/syncfield/adapters/insta360_go3s/aggregation/queue.py b/src/syncfield/adapters/insta360_go3s/aggregation/queue.py index 1930b11..3040d44 100644 --- a/src/syncfield/adapters/insta360_go3s/aggregation/queue.py +++ b/src/syncfield/adapters/insta360_go3s/aggregation/queue.py @@ -15,7 +15,7 @@ import uuid from dataclasses import dataclass from pathlib import Path -from typing import Callable, Optional +from typing import Any, Callable, Optional from .types import ( AggregationCameraSpec, @@ -249,3 +249,74 @@ def _notify(self, progress: AggregationProgress) -> None: def make_job_id() -> str: return f"agg_{uuid.uuid4().hex[:12]}" + + +class Go3SAggregationDownloader(AggregationDownloader): + """Production downloader: switch WiFi -> probe OSC -> download -> restore. + + Always restores the previous WiFi network in a finally block, even when + the download fails. The restore step is best-effort — if it fails, a + warning is logged but the original download error is preserved for the + caller. + """ + + def __init__( + self, + *, + switcher: Any, # WifiSwitcher + osc_factory: Callable[[str], Any], # (host) -> OscHttpClient-like + ap_host: str = "192.168.42.1", + wait_for_ap_timeout: float = 30.0, + ap_probe_attempts: int = 6, + ap_probe_interval: float = 5.0, + ): + self._switcher = switcher + self._osc_factory = osc_factory + self._ap_host = ap_host + self._wait_for_ap_timeout = wait_for_ap_timeout + self._ap_probe_attempts = ap_probe_attempts + self._ap_probe_interval = ap_probe_interval + + async def run( + self, + camera: AggregationCameraSpec, + target_dir: Path, + on_chunk: ChunkCallback, + ) -> None: + prev_ssid = self._switcher.current_ssid() + try: + self._switcher.connect(camera.wifi_ssid, camera.wifi_password) + await self._wait_for_ap() + osc = self._osc_factory(self._ap_host) + await osc.probe(timeout=5.0) + local_path = target_dir / camera.local_filename + await osc.download( + remote_path=camera.sd_path, + local_path=local_path, + expected_size=camera.size_bytes or None, + on_progress=lambda done, total: on_chunk(camera.stream_id, done, total), + ) + finally: + try: + self._switcher.restore(prev_ssid) + except Exception: + _log.warning( + "Go3SAggregationDownloader: failed to restore WiFi to %s", + prev_ssid, + exc_info=True, + ) + + async def _wait_for_ap(self) -> None: + deadline = asyncio.get_event_loop().time() + self._wait_for_ap_timeout + last_error: Exception | None = None + for _ in range(self._ap_probe_attempts): + if asyncio.get_event_loop().time() > deadline: + break + try: + osc = self._osc_factory(self._ap_host) + await osc.probe(timeout=2.0) + return + except Exception as e: + last_error = e + await asyncio.sleep(self._ap_probe_interval) + raise RuntimeError(f"camera AP unreachable: {last_error}") diff --git a/tests/unit/adapters/insta360_go3s/test_aggregation_queue.py b/tests/unit/adapters/insta360_go3s/test_aggregation_queue.py index bf81153..d7ff866 100644 --- a/tests/unit/adapters/insta360_go3s/test_aggregation_queue.py +++ b/tests/unit/adapters/insta360_go3s/test_aggregation_queue.py @@ -159,3 +159,109 @@ async def run(self, camera, target_dir, on_chunk): final2 = await asyncio.wait_for(handle2.wait(), timeout=1.0) assert final2.state == AggregationState.FAILED assert "shut down" in (final2.error or "") + + +class FakeSwitcher: + def __init__(self): + self.calls: list[tuple[str, str | None]] = [] + self._current: str | None = "LabWiFi" + + def current_ssid(self) -> str | None: + return self._current + + def connect(self, ssid: str, password: str) -> None: + self.calls.append(("connect", ssid)) + self._current = ssid + + def restore(self, prev_ssid: str | None, prev_password: str | None = None) -> None: + self.calls.append(("restore", prev_ssid)) + self._current = prev_ssid + + +class FakeOscClient: + def __init__(self, *, fail_probe: bool = False, fail_download: bool = False): + self.fail_probe = fail_probe + self.fail_download = fail_download + self.downloads: list[tuple[str, Path]] = [] + + async def probe(self, *, timeout: float = 5.0): + if self.fail_probe: + raise RuntimeError("probe failed") + from syncfield.adapters.insta360_go3s.wifi.osc_client import OscCameraInfo + return OscCameraInfo(manufacturer="Insta360", model="Go 3S", firmware_version="x") + + async def download(self, *, remote_path: str, local_path: Path, + expected_size: int | None = None, on_progress=None, + port_overrides=None) -> None: + self.downloads.append((remote_path, local_path)) + if self.fail_download: + raise RuntimeError("download failed") + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(b"x" * (expected_size or 0)) + if on_progress: + on_progress(expected_size or 0, expected_size or 0) + + +@pytest.mark.asyncio +async def test_production_downloader_switches_downloads_restores(tmp_path): + from syncfield.adapters.insta360_go3s.aggregation.queue import ( + Go3SAggregationDownloader, + ) + + sw = FakeSwitcher() + osc = FakeOscClient() + + def osc_factory(host: str): + return osc + + downloader = Go3SAggregationDownloader( + switcher=sw, + osc_factory=osc_factory, + wait_for_ap_timeout=0.1, + ap_probe_attempts=1, + ) + cam = AggregationCameraSpec( + stream_id="overhead", + ble_address="AA:BB", + wifi_ssid="Go3S-CAFEBABE.OSC", + wifi_password="88888888", + sd_path="/DCIM/Camera01/VID.mp4", + local_filename="overhead.mp4", + size_bytes=12, + ) + progress: list[tuple[str, int, int]] = [] + await downloader.run(cam, tmp_path, lambda sid, d, t: progress.append((sid, d, t))) + + assert sw.calls == [("connect", "Go3S-CAFEBABE.OSC"), ("restore", "LabWiFi")] + assert (tmp_path / "overhead.mp4").exists() + assert progress[-1] == ("overhead", 12, 12) + + +@pytest.mark.asyncio +async def test_production_downloader_restores_wifi_even_on_failure(tmp_path): + from syncfield.adapters.insta360_go3s.aggregation.queue import ( + Go3SAggregationDownloader, + ) + + sw = FakeSwitcher() + osc = FakeOscClient(fail_download=True) + + downloader = Go3SAggregationDownloader( + switcher=sw, + osc_factory=lambda host: osc, + wait_for_ap_timeout=0.1, + ap_probe_attempts=1, + ) + cam = AggregationCameraSpec( + stream_id="overhead", + ble_address="AA:BB", + wifi_ssid="Go3S-CAFEBABE.OSC", + wifi_password="88888888", + sd_path="/DCIM/Camera01/VID.mp4", + local_filename="overhead.mp4", + size_bytes=12, + ) + with pytest.raises(RuntimeError): + await downloader.run(cam, tmp_path, lambda *args: None) + # Restore must still be called + assert ("restore", "LabWiFi") in sw.calls From 2f338ced2add674c917d66306c75441c38a486b1 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:05:34 -0700 Subject: [PATCH 10/28] feat(go3s): add Go3SStream with deferred aggregation Implements the Go3SStream lifecycle class (Task 10) that subclasses StreamBase and bridges the sync orchestrator API to the async BLE helper, building an AggregationJob on stop_recording and enqueuing it per the configured aggregation_policy. Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/insta360_go3s/__init__.py | 4 + .../adapters/insta360_go3s/stream.py | 273 ++++++++++++++++++ .../insta360_go3s/test_go3s_stream.py | 115 ++++++++ 3 files changed, 392 insertions(+) create mode 100644 src/syncfield/adapters/insta360_go3s/stream.py create mode 100644 tests/unit/adapters/insta360_go3s/test_go3s_stream.py diff --git a/src/syncfield/adapters/insta360_go3s/__init__.py b/src/syncfield/adapters/insta360_go3s/__init__.py index 6406a18..dc2b9b5 100644 --- a/src/syncfield/adapters/insta360_go3s/__init__.py +++ b/src/syncfield/adapters/insta360_go3s/__init__.py @@ -1 +1,5 @@ """Insta360 Go3S adapter (BLE trigger + WiFi aggregation).""" + +from .stream import Go3SStream + +__all__ = ["Go3SStream"] diff --git a/src/syncfield/adapters/insta360_go3s/stream.py b/src/syncfield/adapters/insta360_go3s/stream.py new file mode 100644 index 0000000..6b0b3de --- /dev/null +++ b/src/syncfield/adapters/insta360_go3s/stream.py @@ -0,0 +1,273 @@ +"""Insta360 Go3S Stream — BLE trigger + deferred WiFi aggregation.""" +from __future__ import annotations + +import asyncio +import threading +import time +from concurrent.futures import Future +from pathlib import Path +from typing import Literal, Optional + +from syncfield.clock import SessionClock +from syncfield.stream import StreamBase +from syncfield.types import ( + FinalizationReport, + HealthEvent, + HealthEventKind, + StreamCapabilities, +) + +from .aggregation.queue import ( + AggregationQueue, + Go3SAggregationDownloader, + make_job_id, +) +from .aggregation.types import ( + AggregationCameraSpec, + AggregationJob, + AggregationState, +) +from .ble.camera import Go3SBLECamera +from .wifi.osc_client import OscHttpClient +from .wifi.switcher import wifi_switcher_for_platform + + +AggregationPolicy = Literal["eager", "on_demand"] + +_QUEUE_LOCK = threading.Lock() +_QUEUE: Optional[AggregationQueue] = None +_QUEUE_LOOP: Optional[asyncio.AbstractEventLoop] = None +_QUEUE_THREAD: Optional[threading.Thread] = None + + +def _global_aggregation_queue() -> AggregationQueue: + """Lazy singleton: queue + dedicated background thread + dedicated loop. + + The thread runs the asyncio loop forever (daemon thread, dies with process). + All queue interactions from outside threads must be marshaled via + ``asyncio.run_coroutine_threadsafe(..., _QUEUE_LOOP)`` because + ``asyncio.Queue`` is not thread-safe. + """ + global _QUEUE, _QUEUE_LOOP, _QUEUE_THREAD + with _QUEUE_LOCK: + if _QUEUE is not None: + return _QUEUE + loop = asyncio.new_event_loop() + ready = threading.Event() + + def _run_loop() -> None: + asyncio.set_event_loop(loop) + ready.set() + try: + loop.run_forever() + finally: + loop.close() + + thread = threading.Thread( + target=_run_loop, + name="go3s-aggregation", + daemon=True, + ) + thread.start() + ready.wait(timeout=5.0) + + switcher = wifi_switcher_for_platform() + downloader = Go3SAggregationDownloader( + switcher=switcher, + osc_factory=lambda host: OscHttpClient(host=host), + ) + queue = AggregationQueue(downloader=downloader) + # Start the worker on the dedicated loop and wait for it to be running. + fut: Future = asyncio.run_coroutine_threadsafe(queue.start(), loop) + fut.result(timeout=5.0) + + _QUEUE_LOOP = loop + _QUEUE_THREAD = thread + _QUEUE = queue + return _QUEUE + + +async def _enqueue_async(queue: AggregationQueue, job: AggregationJob) -> None: + queue.enqueue(job) + + +def _enqueue_on_global_queue(job: AggregationJob) -> None: + """Thread-safe enqueue marshaled onto the queue's owned loop. + + When ``_global_aggregation_queue`` has been monkeypatched (unit tests), + ``_QUEUE_LOOP`` may be ``None``; in that case we call ``enqueue`` directly + on the (mock) queue since there's no dedicated loop to marshal onto. + """ + queue = _global_aggregation_queue() + if _QUEUE_LOOP is None: + queue.enqueue(job) + return + fut: Future = asyncio.run_coroutine_threadsafe( + _enqueue_async(queue, job), _QUEUE_LOOP + ) + fut.result(timeout=5.0) + + +class Go3SStream(StreamBase): + """Insta360 Go3S adapter — wireless start/stop + background aggregation. + + Args: + stream_id: Stream id. + ble_address: BLE MAC (or platform UUID on macOS) of the Go3S camera. + output_dir: Episode directory; aggregated files land here. + aggregation_policy: v1 supports two policies — ``"eager"`` (default; + enqueue immediately on stop) and ``"on_demand"`` (enqueue only + when the viewer or caller explicitly triggers via + :attr:`pending_aggregation_job`). + wifi_ssid: Camera AP SSID. Auto-derived from ``ble_address`` on first + connect when omitted. + wifi_password: Camera AP password (Insta360 default is ``"88888888"``). + """ + + def __init__( + self, + stream_id: str, + *, + ble_address: str, + output_dir: Path, + aggregation_policy: AggregationPolicy = "eager", + wifi_ssid: Optional[str] = None, + wifi_password: str = "88888888", + ): + super().__init__( + id=stream_id, + kind="video", + capabilities=StreamCapabilities( + provides_audio_track=False, + supports_precise_timestamps=False, + is_removable=True, + produces_file=True, + live_preview=False, + ), + ) + self._ble_address = ble_address + self._output_dir = Path(output_dir) + self._aggregation_policy: AggregationPolicy = aggregation_policy + self._wifi_ssid = wifi_ssid # auto-derived from BLE addr on first connect + self._wifi_password = wifi_password + self._start_ack_ns: Optional[int] = None + self._stop_ack_ns: Optional[int] = None + self._sd_path: Optional[str] = None + self.pending_aggregation_job: Optional[AggregationJob] = None + """Last job built by stop_recording() — set after each stop, never cleared. + + For on_demand policy this is the handle the caller uses to trigger + aggregation manually. For eager policy it's set as a side effect but + the worker will already have it; reading it is mostly diagnostic. + """ + + # ----- Stream protocol ----- + + @property + def device_key(self): # type: ignore[override] + return ("go3s", self._ble_address) + + def prepare(self) -> None: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.HEARTBEAT, + at_ns=time.monotonic_ns(), + detail="Go3S prepared", + ) + ) + + def connect(self) -> None: + # Quick BLE handshake to verify reachability + auto-derive SSID; then disconnect. + self._run_async(self._verify_reachable()) + + def start_recording(self, session_clock: SessionClock) -> None: + self._run_async(self._do_start()) + + def stop_recording(self) -> FinalizationReport: + self._run_async(self._do_stop()) + job = self._build_job() + self.pending_aggregation_job = job + if self._aggregation_policy == "eager": + self._enqueue_job(job) + # "on_demand": leave job pending; orchestrator/viewer triggers later. + return FinalizationReport( + stream_id=self.id, + status="pending_aggregation", + frame_count=0, + file_path=None, + first_sample_at_ns=self._start_ack_ns, + last_sample_at_ns=self._stop_ack_ns, + health_events=list(self._collected_health), + error=None, + ) + + def disconnect(self) -> None: + # Aggregation runs independently; nothing to tear down synchronously. + pass + + # ----- internals ----- + + async def _verify_reachable(self) -> None: + cam = Go3SBLECamera(self._ble_address) + await cam.connect(sync_timeout=2.0, auth_timeout=1.0) + if self._wifi_ssid is None: + self._wifi_ssid = self._derive_ssid_from_address(self._ble_address) + await cam.disconnect() + + async def _do_start(self) -> None: + cam = Go3SBLECamera(self._ble_address) + await cam.connect() + try: + self._start_ack_ns = await cam.start_capture() + finally: + await cam.disconnect() + + async def _do_stop(self) -> None: + cam = Go3SBLECamera(self._ble_address) + await cam.connect() + try: + result = await cam.stop_capture() + self._stop_ack_ns = result.ack_host_ns + self._sd_path = result.file_path + finally: + await cam.disconnect() + + def _build_job(self) -> AggregationJob: + if self._sd_path is None: + raise RuntimeError("stop_recording did not return a file path") + ext = ".mp4" if self._sd_path.lower().endswith(".mp4") else ".insv" + camera_spec = AggregationCameraSpec( + stream_id=self.id, + ble_address=self._ble_address, + wifi_ssid=self._wifi_ssid or self._derive_ssid_from_address(self._ble_address), + wifi_password=self._wifi_password, + sd_path=self._sd_path, + local_filename=f"{self.id}{ext}", + size_bytes=0, # populated by OSC listFiles in production downloader + ) + return AggregationJob( + job_id=make_job_id(), + episode_id=self._output_dir.name, + episode_dir=self._output_dir, + cameras=[camera_spec], + state=AggregationState.PENDING, + ) + + def _enqueue_job(self, job: AggregationJob) -> None: + _enqueue_on_global_queue(job) + + @staticmethod + def _derive_ssid_from_address(address: str) -> str: + suffix = address.replace(":", "").upper()[-12:] + return f"Go3S-{suffix}.OSC" + + def _run_async(self, coro) -> None: + """Bridge sync Stream API to the async BLE helper. + + Each call creates a fresh asyncio loop. If called from inside an + already-running loop (e.g., from an async test), ``asyncio.run()`` + will raise ``RuntimeError`` — that's intentional; callers must + arrange a thread boundary themselves. + """ + asyncio.run(coro) diff --git a/tests/unit/adapters/insta360_go3s/test_go3s_stream.py b/tests/unit/adapters/insta360_go3s/test_go3s_stream.py new file mode 100644 index 0000000..dfa51d0 --- /dev/null +++ b/tests/unit/adapters/insta360_go3s/test_go3s_stream.py @@ -0,0 +1,115 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from syncfield.adapters.insta360_go3s import Go3SStream +from syncfield.adapters.insta360_go3s.aggregation.queue import AggregationQueue +from syncfield.adapters.insta360_go3s.aggregation.types import AggregationState +from syncfield.adapters.insta360_go3s.ble.camera import CaptureResult +from syncfield.types import StreamCapabilities + + +@pytest.fixture +def fake_ble(monkeypatch): + """Replace Go3SBLECamera with an async-mock factory.""" + fake = AsyncMock() + fake.connect = AsyncMock() + fake.disconnect = AsyncMock() + fake.set_video_mode = AsyncMock() + fake.start_capture = AsyncMock(return_value=12345) # fake host_ns + fake.stop_capture = AsyncMock( + return_value=CaptureResult( + file_path="/DCIM/Camera01/VID_FAKE.mp4", ack_host_ns=23456 + ) + ) + + def factory(address): + return fake + + monkeypatch.setattr( + "syncfield.adapters.insta360_go3s.stream.Go3SBLECamera", factory + ) + return fake + + +@pytest.fixture +def fake_queue(monkeypatch): + queue = MagicMock(spec=AggregationQueue) + queue.enqueue = MagicMock() + monkeypatch.setattr( + "syncfield.adapters.insta360_go3s.stream._global_aggregation_queue", + lambda: queue, + ) + return queue + + +def test_capabilities_indicate_no_live_preview_and_produces_file(fake_ble, tmp_path): + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + ) + caps: StreamCapabilities = s.capabilities + assert caps.live_preview is False + assert caps.produces_file is True + assert caps.is_removable is True + + +def test_device_key_is_go3s_with_address(fake_ble, tmp_path): + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + ) + assert s.device_key == ("go3s", "AA:BB:CC:DD:EE:FF") + + +def test_kind_is_video(fake_ble, tmp_path): + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + ) + assert s.kind == "video" + + +def test_full_lifecycle_enqueues_aggregation(fake_ble, fake_queue, tmp_path): + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + ) + s.prepare() + s.connect() + # start_recording is a sync API; the implementation runs the async work internally. + s.start_recording(session_clock=MagicMock()) + report = s.stop_recording() + s.disconnect() + + assert report.status == "pending_aggregation" + assert report.stream_id == "overhead" + fake_queue.enqueue.assert_called_once() + enq_job = fake_queue.enqueue.call_args.args[0] + assert enq_job.cameras[0].stream_id == "overhead" + assert enq_job.cameras[0].sd_path == "/DCIM/Camera01/VID_FAKE.mp4" + assert enq_job.cameras[0].local_filename == "overhead.mp4" + + +def test_on_demand_policy_does_not_enqueue(fake_ble, fake_queue, tmp_path): + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + aggregation_policy="on_demand", + ) + s.prepare() + s.connect() + s.start_recording(session_clock=MagicMock()) + report = s.stop_recording() + assert report.status == "pending_aggregation" + assert not fake_queue.enqueue.called + # An ID for manual aggregation later should still be exposed + assert s.pending_aggregation_job is not None + assert s.pending_aggregation_job.cameras[0].sd_path == "/DCIM/Camera01/VID_FAKE.mp4" From 96186deac05608c92fe30e069cf67d16966c1eaf Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:16:06 -0700 Subject: [PATCH 11/28] feat(adapters): re-export Go3SStream and register for discovery Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/syncfield/adapters/__init__.py b/src/syncfield/adapters/__init__.py index 5b9ffeb..bd811ef 100644 --- a/src/syncfield/adapters/__init__.py +++ b/src/syncfield/adapters/__init__.py @@ -13,6 +13,7 @@ ``BLEImuGenericStream`` ``bleak`` ``syncfield[ble]`` ``OgloTactileStream`` ``bleak`` ``syncfield[ble]`` ``OakCameraStream`` ``depthai`` + ``av`` ``syncfield[oak]`` +``Go3SStream`` ``bleak`` + ``aiohttp`` ``syncfield[camera]`` ========================= ===================================== ============================= Users who need a specific optional adapter can always import it directly @@ -95,3 +96,10 @@ def _safe_register(cls) -> None: _safe_register(OakCameraStream) except ImportError: pass + +try: + from syncfield.adapters.insta360_go3s import Go3SStream # noqa: F401 + __all__.append("Go3SStream") + _safe_register(Go3SStream) +except ImportError: + pass From ce4fb76231ccf6f898245c518d1baf4b29ee77fc Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:17:55 -0700 Subject: [PATCH 12/28] feat(orchestrator): downgrade Go3S aggregation to on_demand for multihost roles When a Go3SStream with aggregation_policy="eager" is added to a session whose role is LeaderRole or FollowerRole, the policy is automatically downgraded to "on_demand" so the host's WiFi adapter stays connected to lab WiFi (needed for mDNS multihost coordination) instead of switching to the camera AP during recording. Co-Authored-By: Claude Sonnet 4.6 --- src/syncfield/orchestrator.py | 22 ++++++ ...test_orchestrator_go3s_policy_downgrade.py | 73 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/unit/test_orchestrator_go3s_policy_downgrade.py diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index bbf51cf..c5dfe4a 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -1445,6 +1445,28 @@ def add(self, stream: Stream) -> None: f"physical device {new_key} is already registered " f"as stream {existing.id!r}" ) + # Multihost role-aware policy downgrade for Go3S streams: the leader/ + # follower communicate over lab WiFi (mDNS). Switching the host adapter + # to a camera AP during a session breaks coordination. Force on_demand + # so aggregation runs only when explicitly triggered by the viewer. + try: + from syncfield.adapters.insta360_go3s import Go3SStream as _Go3SStream + if ( + isinstance(stream, _Go3SStream) + and stream._aggregation_policy == "eager" + and isinstance(self._role, (LeaderRole, FollowerRole)) + ): + stream._aggregation_policy = "on_demand" + logger.info( + "Go3S stream %r: aggregation_policy downgraded eager→on_demand " + "for multihost role %r (lab WiFi must stay connected for mDNS)", + stream.id, + self._role.kind, + ) + except ImportError: + # Adapter not installed (no 'camera' extra); nothing to downgrade. + pass + self._streams[stream.id] = stream stream.on_health(self._on_stream_health) diff --git a/tests/unit/test_orchestrator_go3s_policy_downgrade.py b/tests/unit/test_orchestrator_go3s_policy_downgrade.py new file mode 100644 index 0000000..5fc0774 --- /dev/null +++ b/tests/unit/test_orchestrator_go3s_policy_downgrade.py @@ -0,0 +1,73 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from syncfield.adapters.insta360_go3s import Go3SStream +from syncfield.orchestrator import SessionOrchestrator +from syncfield.roles import LeaderRole, FollowerRole + + +@patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera") +def test_eager_downgrades_to_on_demand_when_leader(_mock_cam, tmp_path): + session = SessionOrchestrator( + host_id="mac", + output_dir=tmp_path, + role=LeaderRole(session_id="sess_x"), + ) + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + aggregation_policy="eager", + ) + session.add(s) + assert s._aggregation_policy == "on_demand" + + +@patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera") +def test_eager_downgrades_to_on_demand_when_follower(_mock_cam, tmp_path): + session = SessionOrchestrator( + host_id="mac", + output_dir=tmp_path, + role=FollowerRole(session_id="sess_x"), + ) + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + aggregation_policy="eager", + ) + session.add(s) + assert s._aggregation_policy == "on_demand" + + +@patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera") +def test_eager_unchanged_when_single_host(_mock_cam, tmp_path): + session = SessionOrchestrator(host_id="mac", output_dir=tmp_path) + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + aggregation_policy="eager", + ) + session.add(s) + assert s._aggregation_policy == "eager" + + +@patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera") +def test_on_demand_unchanged_when_leader(_mock_cam, tmp_path): + """Explicit on_demand stays on_demand even with leader role.""" + session = SessionOrchestrator( + host_id="mac", + output_dir=tmp_path, + role=LeaderRole(session_id="sess_x"), + ) + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + aggregation_policy="on_demand", + ) + session.add(s) + assert s._aggregation_policy == "on_demand" From 36a6803ccdcca619ab0c24e11e2b898ab89f9951 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:23:41 -0700 Subject: [PATCH 13/28] feat(viewer): expose aggregation state in WS snapshot Add AggregationSnapshot dataclass to state.py and wire it into snapshot_to_dict so the /ws/control and /api/status payloads include an aggregation section (active_job, queue_length, recent_jobs). A best-effort listener on the global Go3S aggregation queue keeps the state live; a no-op if the camera extra is not installed. Co-Authored-By: Claude Sonnet 4.6 --- src/syncfield/viewer/server.py | 69 ++++++++++++++++++- src/syncfield/viewer/state.py | 19 +++++ .../unit/test_viewer_aggregation_snapshot.py | 42 +++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_viewer_aggregation_snapshot.py diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py index 8f132ac..708a941 100644 --- a/src/syncfield/viewer/server.py +++ b/src/syncfield/viewer/server.py @@ -36,7 +36,7 @@ from syncfield.orchestrator import SessionOrchestrator from syncfield.viewer.poller import SessionPoller -from syncfield.viewer.state import HealthEntry, SessionSnapshot, StreamSnapshot +from syncfield.viewer.state import AggregationSnapshot, HealthEntry, SessionSnapshot, StreamSnapshot logger = logging.getLogger(__name__) @@ -49,6 +49,17 @@ # --------------------------------------------------------------------------- +def _serialize_aggregation(agg) -> Dict[str, Any]: + """Serialize an AggregationSnapshot (or None) to a JSON-serializable dict.""" + if agg is None: + return {"active_job": None, "queue_length": 0, "recent_jobs": []} + return { + "active_job": agg.active_job.to_dict() if agg.active_job else None, + "queue_length": getattr(agg, "queue_length", 0), + "recent_jobs": [j.to_dict() for j in getattr(agg, "recent_jobs", [])], + } + + def snapshot_to_dict(snapshot: SessionSnapshot) -> Dict[str, Any]: """Convert a frozen SessionSnapshot to a JSON-serializable dict. @@ -107,6 +118,7 @@ def snapshot_to_dict(snapshot: SessionSnapshot) -> Dict[str, Any]: "streams": streams, "health_log": health_log, "output_dir": snapshot.output_dir, + "aggregation": _serialize_aggregation(getattr(snapshot, "aggregation", None)), } @@ -207,6 +219,53 @@ def _require_leader(orch) -> None: ) +# --------------------------------------------------------------------------- +# Aggregation listener wiring +# --------------------------------------------------------------------------- + +# Rolling window of completed/failed jobs (trimmed to 5 most recent). +_recent_agg_jobs: List[Any] = [] + + +def _attach_aggregation_listener(server: "ViewerServer") -> None: + """Best-effort: subscribe to the global Go3S aggregation queue. + + No-op if the Go3S camera extra is not installed. Updates + ``server._agg_state`` so the broadcast loop can inject the latest + aggregation state into each WS snapshot without requiring a poller + change. + """ + try: + from syncfield.adapters.insta360_go3s.stream import _global_aggregation_queue + from syncfield.adapters.insta360_go3s.aggregation.types import AggregationState + except ImportError: + return + + def on_progress(progress) -> None: + if progress.state == AggregationState.RUNNING: + active = progress + else: + active = None + if progress.state in (AggregationState.COMPLETED, AggregationState.FAILED): + _recent_agg_jobs.append(progress) + if len(_recent_agg_jobs) > 5: + del _recent_agg_jobs[: len(_recent_agg_jobs) - 5] + server._agg_state = AggregationSnapshot( + active_job=active, + queue_length=0, # populated by queue if exposed; staying 0 in v1 + recent_jobs=list(_recent_agg_jobs), + ) + + try: + _global_aggregation_queue().subscribe(on_progress) + except Exception: + logger.warning( + "Failed to subscribe to global aggregation queue; " + "aggregation state will not be surfaced in the WS snapshot.", + exc_info=True, + ) + + # --------------------------------------------------------------------------- # Server class # --------------------------------------------------------------------------- @@ -232,6 +291,7 @@ def __init__( self._title = title self._sync_endpoint = sync_endpoint.rstrip("/") self._ws_clients: Set[WebSocket] = set() + self._agg_state: Optional[AggregationSnapshot] = None self.app = FastAPI(title=title, docs_url=None, redoc_url=None) self._setup_middleware() @@ -240,6 +300,7 @@ def __init__( self._setup_task_routes() self._setup_episode_routes() self._setup_static() + _attach_aggregation_listener(self) # ------------------------------------------------------------------ # Middleware @@ -1586,6 +1647,12 @@ async def _broadcast_loop(self, ws: WebSocket) -> None: snapshot = self._poller.get_snapshot() if snapshot is not None: try: + # Inject current aggregation state if the poller snapshot + # doesn't carry one (the default case for non-Go3S sessions). + if snapshot.aggregation is None and self._agg_state is not None: + snapshot = dataclasses.replace( + snapshot, aggregation=self._agg_state + ) payload = snapshot_to_dict(snapshot) await ws.send_text(json.dumps(payload)) except Exception: diff --git a/src/syncfield/viewer/state.py b/src/syncfield/viewer/state.py index 1d520a7..4452e1d 100644 --- a/src/syncfield/viewer/state.py +++ b/src/syncfield/viewer/state.py @@ -24,6 +24,24 @@ from typing import Any, Deque, Dict, List, Optional, Tuple +# --------------------------------------------------------------------------- +# Aggregation snapshot +# --------------------------------------------------------------------------- + + +@dataclass +class AggregationSnapshot: + """Mutable aggregation state surfaced into the viewer snapshot. + + Uses a plain (non-frozen) dataclass so the listener in server.py can + update it in-place without reconstructing the SessionSnapshot. + """ + + active_job: Optional[Any] = None # AggregationProgress; Any for optional-extra safety + queue_length: int = 0 + recent_jobs: List[Any] = field(default_factory=list) + + # --------------------------------------------------------------------------- # Stream-level snapshot # --------------------------------------------------------------------------- @@ -118,6 +136,7 @@ class SessionSnapshot: elapsed_s: float streams: Dict[str, StreamSnapshot] health_log: List[HealthEntry] + aggregation: Optional[AggregationSnapshot] = None # --------------------------------------------------------------------------- diff --git a/tests/unit/test_viewer_aggregation_snapshot.py b/tests/unit/test_viewer_aggregation_snapshot.py new file mode 100644 index 0000000..47ff2a4 --- /dev/null +++ b/tests/unit/test_viewer_aggregation_snapshot.py @@ -0,0 +1,42 @@ +from unittest.mock import MagicMock + +import pytest + +from syncfield.adapters.insta360_go3s.aggregation.types import ( + AggregationProgress, + AggregationState, +) +from syncfield.viewer.server import snapshot_to_dict + + +def test_snapshot_includes_aggregation_section_empty_by_default(): + snapshot = MagicMock() + snapshot.aggregation = None + d = snapshot_to_dict(snapshot) + assert "aggregation" in d + assert d["aggregation"]["active_job"] is None + assert d["aggregation"]["queue_length"] == 0 + assert d["aggregation"]["recent_jobs"] == [] + + +def test_snapshot_serializes_active_job(): + progress = AggregationProgress( + job_id="agg_x", + episode_id="ep_x", + state=AggregationState.RUNNING, + cameras_total=2, + cameras_done=1, + current_stream_id="overhead", + current_bytes=5_000_000, + current_total_bytes=10_000_000, + ) + snapshot = MagicMock() + snapshot.aggregation = MagicMock() + snapshot.aggregation.active_job = progress + snapshot.aggregation.queue_length = 1 + snapshot.aggregation.recent_jobs = [progress] + d = snapshot_to_dict(snapshot) + assert d["aggregation"]["active_job"]["state"] == "running" + assert d["aggregation"]["active_job"]["current_bytes"] == 5_000_000 + assert d["aggregation"]["queue_length"] == 1 + assert len(d["aggregation"]["recent_jobs"]) == 1 From c537cfdf4c9f18ab07ea683cde011b828bfc74b2 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:28:54 -0700 Subject: [PATCH 14/28] feat(viewer): add aggregation control commands Add handle_control_command() dispatcher in viewer/server.py for the three new WS commands (aggregate_episode, retry_aggregation, cancel_aggregation), and add the corresponding methods to SessionOrchestrator that delegate to the Go3S aggregation queue. Co-Authored-By: Claude Sonnet 4.6 --- src/syncfield/orchestrator.py | 51 ++++++++++ src/syncfield/viewer/server.py | 31 ++++++ .../unit/test_viewer_aggregation_commands.py | 94 +++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 tests/unit/test_viewer_aggregation_commands.py diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index c5dfe4a..2fe0b39 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -3087,6 +3087,57 @@ def _poll_static_leader_until_stopped(self, timeout: float) -> None: ) + # ------------------------------------------------------------------ + # Aggregation control — Go3S on-demand / retry + # ------------------------------------------------------------------ + + def aggregate_episode(self, episode_id: str) -> None: + """Trigger on-demand aggregation for an episode that is pending. + + Searches all registered streams for a Go3S stream whose + ``pending_aggregation_job`` matches *episode_id* and enqueues + that job on the global aggregation queue. + + Raises: + RuntimeError: If the Go3S adapter is not installed. + KeyError: If no matching pending job is found. + """ + try: + from syncfield.adapters.insta360_go3s.stream import ( + _enqueue_on_global_queue, + ) + except ImportError: + raise RuntimeError("Go3S adapter not installed (missing 'camera' extra)") + + for stream in self._streams.values(): + pending = getattr(stream, "pending_aggregation_job", None) + if pending is not None and pending.episode_id == episode_id: + _enqueue_on_global_queue(pending) + return + raise KeyError(f"No pending aggregation for episode {episode_id!r}") + + def retry_aggregation(self, job_id: str) -> None: + """Re-enqueue a previously-failed aggregation job. + + Delegates to :meth:`AggregationQueue.retry` on the global queue. + + Raises: + RuntimeError: If the Go3S adapter is not installed. + """ + try: + from syncfield.adapters.insta360_go3s.stream import _global_aggregation_queue + except ImportError: + raise RuntimeError("Go3S adapter not installed (missing 'camera' extra)") + _global_aggregation_queue().retry(job_id) + + def cancel_aggregation(self, job_id: str) -> None: # noqa: ARG002 + """Cancel an in-flight or queued aggregation job. + + Not implemented in v1 — raises ``NotImplementedError``. + """ + raise NotImplementedError("cancel_aggregation deferred to v2") + + class _ControlPlaneOrchestratorAdapter: """Narrow adapter between SessionOrchestrator and FastAPI routes. diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py index 708a941..e0f69e2 100644 --- a/src/syncfield/viewer/server.py +++ b/src/syncfield/viewer/server.py @@ -266,6 +266,37 @@ def on_progress(progress) -> None: ) +# --------------------------------------------------------------------------- +# Aggregation control command dispatcher +# --------------------------------------------------------------------------- + + +def handle_control_command(orchestrator: "SessionOrchestrator", payload: dict) -> dict: + """Dispatch an aggregation control command from a WebSocket client. + + Handles the three aggregation commands introduced in T14. All other + (legacy) commands continue to be handled inside ``_handle_command``. + + Returns a ``{"ok": True}`` dict on success, or + ``{"ok": False, "error": ""}`` on failure — including + ``NotImplementedError`` for commands deferred to v2. + """ + cmd = payload.get("command") + try: + if cmd == "aggregate_episode": + orchestrator.aggregate_episode(payload["episode_id"]) + return {"ok": True} + if cmd == "retry_aggregation": + orchestrator.retry_aggregation(payload["job_id"]) + return {"ok": True} + if cmd == "cancel_aggregation": + orchestrator.cancel_aggregation(payload["job_id"]) + return {"ok": True} + return {"ok": False, "error": f"unknown command: {cmd}"} + except Exception as exc: + return {"ok": False, "error": str(exc)} + + # --------------------------------------------------------------------------- # Server class # --------------------------------------------------------------------------- diff --git a/tests/unit/test_viewer_aggregation_commands.py b/tests/unit/test_viewer_aggregation_commands.py new file mode 100644 index 0000000..81a5284 --- /dev/null +++ b/tests/unit/test_viewer_aggregation_commands.py @@ -0,0 +1,94 @@ +from unittest.mock import MagicMock + +import pytest + + +def test_aggregate_episode_dispatches_to_orchestrator(): + from syncfield.viewer.server import handle_control_command + + orch = MagicMock() + result = handle_control_command(orch, {"command": "aggregate_episode", "episode_id": "ep_x"}) + orch.aggregate_episode.assert_called_once_with("ep_x") + assert result["ok"] is True + + +def test_retry_aggregation_dispatches_to_queue(): + from syncfield.viewer.server import handle_control_command + + orch = MagicMock() + result = handle_control_command(orch, {"command": "retry_aggregation", "job_id": "agg_x"}) + orch.retry_aggregation.assert_called_once_with("agg_x") + assert result["ok"] is True + + +def test_cancel_aggregation_dispatches_to_queue(): + from syncfield.viewer.server import handle_control_command + + orch = MagicMock() + # cancel raises NotImplementedError on the orch side; the dispatcher should + # surface that as a structured error rather than letting it propagate. + orch.cancel_aggregation.side_effect = NotImplementedError("v2 only") + result = handle_control_command(orch, {"command": "cancel_aggregation", "job_id": "agg_x"}) + assert result["ok"] is False + assert "v2" in result["error"] or "NotImpl" in result["error"] + + +def test_unknown_command_returns_error(): + from syncfield.viewer.server import handle_control_command + + orch = MagicMock() + result = handle_control_command(orch, {"command": "no_such_command"}) + assert result["ok"] is False + assert "unknown" in result["error"].lower() + + +def test_orchestrator_aggregate_episode_finds_pending_job(): + """Orchestrator.aggregate_episode locates the right Go3SStream by episode_id.""" + from unittest.mock import patch + + with patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera"): + from syncfield.adapters.insta360_go3s import Go3SStream + from syncfield.adapters.insta360_go3s.aggregation.queue import AggregationQueue + from syncfield.adapters.insta360_go3s.aggregation.types import ( + AggregationCameraSpec, AggregationJob, AggregationState, + ) + from syncfield.orchestrator import SessionOrchestrator + + # Build orchestrator + stream; manually set pending_aggregation_job + from pathlib import Path + import tempfile + tmpdir = Path(tempfile.mkdtemp()) + session = SessionOrchestrator(host_id="mac", output_dir=tmpdir) + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmpdir / "ep_test", + aggregation_policy="on_demand", + ) + session.add(s) + s.pending_aggregation_job = AggregationJob( + job_id="agg_test", + episode_id="ep_test", + episode_dir=tmpdir / "ep_test", + cameras=[ + AggregationCameraSpec( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + wifi_ssid="Go3S-X.OSC", + wifi_password="88888888", + sd_path="/DCIM/Camera01/X.mp4", + local_filename="overhead.mp4", + size_bytes=0, + ) + ], + state=AggregationState.PENDING, + ) + + # Patch the global queue helper so we don't actually start the worker thread + fake_queue = MagicMock(spec=AggregationQueue) + with patch( + "syncfield.adapters.insta360_go3s.stream._global_aggregation_queue", + return_value=fake_queue, + ): + session.aggregate_episode("ep_test") + fake_queue.enqueue.assert_called_once() From c4423787f2a58410c12b3c0ee82c494464a9158b Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:35:05 -0700 Subject: [PATCH 15/28] feat(viewer): add StandaloneRecorderPanel for Go3S streams Renders a standalone-recorder card body (no live preview) for video streams where capabilities.live_preview is false, showing recording / aggregating / ready / failed states with a Retry button on failure. - New component: standalone-recorder-panel.tsx - stream-card.tsx: dispatcher branch for live_preview=false video streams - types.ts: StreamCapabilities, AggregationSnapshotWS, AggregationActiveJob, extended ControlAction with aggregation commands - state.py / poller.py: surface live_preview from StreamCapabilities - server.py: emit capabilities in WS stream dict; route aggregation action commands (retry_aggregation, cancel_aggregation, aggregate_episode) through handle_control_command in _handle_command Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/viewer/frontend/src/App.tsx | 5 + .../components/standalone-recorder-panel.tsx | 207 ++++++++++++++++++ .../frontend/src/components/stream-card.tsx | 137 +++++++++++- .../viewer/frontend/src/lib/types.ts | 42 +++- .../viewer/frontend/tsconfig.tsbuildinfo | 2 +- src/syncfield/viewer/poller.py | 1 + src/syncfield/viewer/server.py | 15 ++ src/syncfield/viewer/state.py | 1 + 8 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 src/syncfield/viewer/frontend/src/components/standalone-recorder-panel.tsx diff --git a/src/syncfield/viewer/frontend/src/App.tsx b/src/syncfield/viewer/frontend/src/App.tsx index b62d2ca..e587515 100644 --- a/src/syncfield/viewer/frontend/src/App.tsx +++ b/src/syncfield/viewer/frontend/src/App.tsx @@ -139,6 +139,11 @@ function RecordView({ stream={stream} canRemove={canRemove} onRemove={handleRemoveStream} + sessionState={state} + aggregation={snapshot?.aggregation} + onRetryAggregation={(jobId) => + sendCommand("retry_aggregation", { job_id: jobId }) + } /> ))} diff --git a/src/syncfield/viewer/frontend/src/components/standalone-recorder-panel.tsx b/src/syncfield/viewer/frontend/src/components/standalone-recorder-panel.tsx new file mode 100644 index 0000000..c9b4621 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/standalone-recorder-panel.tsx @@ -0,0 +1,207 @@ +import { useMemo } from "react"; +import type { AggregationActiveJob, AggregationState } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Prop types +// --------------------------------------------------------------------------- + +export interface StandaloneRecorderStream { + id: string; + /** Session-level state (from SessionSnapshot.state). */ + sessionState: string; + frame_count: number; +} + +export interface StandaloneRecorderPanelProps { + stream: StandaloneRecorderStream; + aggregation: AggregationActiveJob | null; + onRetry?: () => void; +} + +// --------------------------------------------------------------------------- +// Derived status +// --------------------------------------------------------------------------- + +type StatusKind = "recording" | "aggregating" | "ready" | "failed" | "idle"; + +interface DerivedStatus { + kind: StatusKind; + dot: "rec" | "agg" | "ok" | "fail" | "idle"; + label: string; + // recording + frameCount?: number; + // aggregating + currentBytes?: number; + totalBytes?: number; + camerasDone?: number; + camerasTotal?: number; +} + +function deriveStatus( + stream: StandaloneRecorderStream, + agg: AggregationActiveJob | null, +): DerivedStatus { + if (stream.sessionState === "recording") { + return { + kind: "recording", + dot: "rec", + label: "Recording", + frameCount: stream.frame_count, + }; + } + if (agg?.state === "running") { + return { + kind: "aggregating", + dot: "agg", + label: "Aggregating", + currentBytes: agg.current_bytes, + totalBytes: agg.current_total_bytes, + camerasDone: agg.cameras_done, + camerasTotal: agg.cameras_total, + }; + } + if (agg?.state === "failed") { + return { kind: "failed", dot: "fail", label: "Failed" }; + } + if (agg?.state === "completed") { + return { kind: "ready", dot: "ok", label: "Ready" }; + } + if (agg?.state === "pending") { + return { kind: "idle", dot: "idle", label: "Pending aggregation" }; + } + return { kind: "idle", dot: "idle", label: "Idle" }; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function StandaloneRecorderPanel({ + stream, + aggregation, + onRetry, +}: StandaloneRecorderPanelProps) { + const status = useMemo( + () => deriveStatus(stream, aggregation), + [stream, aggregation], + ); + + return ( +
+ + +
+

Standalone recorder

+

Live preview unavailable

+
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Status row +// --------------------------------------------------------------------------- + +function StatusRow({ + status, + onRetry, +}: { + status: DerivedStatus; + onRetry?: () => void; +}) { + const dotClass = cn( + "inline-block h-2 w-2 shrink-0 rounded-full", + { + "bg-recording animate-pulse-recording": status.dot === "rec", + "bg-warning animate-pulse-recording": status.dot === "agg", + "bg-success": status.dot === "ok", + "bg-destructive": status.dot === "fail", + "bg-muted": status.dot === "idle", + }, + ); + + return ( +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Camera SVG glyph +// --------------------------------------------------------------------------- + +function CameraGlyph({ className }: { className?: string }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +// Re-export AggregationState so callers don't need a separate import. +export type { AggregationState }; diff --git a/src/syncfield/viewer/frontend/src/components/stream-card.tsx b/src/syncfield/viewer/frontend/src/components/stream-card.tsx index 4dafab1..fd962e0 100644 --- a/src/syncfield/viewer/frontend/src/components/stream-card.tsx +++ b/src/syncfield/viewer/frontend/src/components/stream-card.tsx @@ -1,24 +1,126 @@ -import type { StreamSnapshot } from "@/lib/types"; +import type { AggregationSnapshotWS, StreamSnapshot } from "@/lib/types"; import { formatCount, formatHz } from "@/lib/format"; import { cn } from "@/lib/utils"; import { AudioLevelChart } from "./audio-level-chart"; import { VideoPreview } from "./video-preview"; import { SensorChart } from "./sensor-chart"; +import { + StandaloneRecorderPanel, + type StandaloneRecorderStream, +} from "./standalone-recorder-panel"; interface StreamCardProps { stream: StreamSnapshot; canRemove: boolean; onRemove: (streamId: string) => void; + /** Session state string — forwarded to StandaloneRecorderPanel for recording detection. */ + sessionState?: string; + /** Top-level aggregation snapshot from the WS payload — used by StandaloneRecorderPanel. */ + aggregation?: AggregationSnapshotWS; + /** Callback to send an aggregation retry command for a given job ID. */ + onRetryAggregation?: (jobId: string) => void; } /** * Per-stream card with variant body by kind. * + * - **video (live_preview=false)** — StandaloneRecorderPanel (Go3S, etc.) * - **video** — MJPEG preview via `` * - **sensor** — Real-time SVG line chart via SSE * - **audio / custom** — Minimal stats placeholder */ -export function StreamCard({ stream, canRemove, onRemove }: StreamCardProps) { +export function StreamCard({ + stream, + canRemove, + onRemove, + sessionState, + aggregation, + onRetryAggregation, +}: StreamCardProps) { + // Dispatch to StandaloneRecorderPanel for video streams without live preview + // (e.g. Insta360 Go3S which downloads files via BLE/Wi-Fi after recording). + const isStandalone = + stream.kind === "video" && stream.capabilities?.live_preview === false; + + if (isStandalone) { + const standaloneStream: StandaloneRecorderStream = { + id: stream.id, + sessionState: sessionState ?? "idle", + frame_count: stream.frame_count, + }; + const activeJob = mapAggregationForStream(aggregation, stream.id); + return ( +
+ {/* Card header */} +
+ + + {stream.id} + +
+ {canRemove && ( + + )} +
+ + {/* Tags */} +
+ {stream.kind} + standalone + {stream.produces_file && file} +
+ + {/* Body */} +
+ onRetryAggregation(activeJob.job_id) + : undefined + } + /> +
+ + {/* Footer stats */} +
+ {formatCount(stream.frame_count)} + + {formatHz(stream.effective_hz)} + {stream.problem_count > 0 && ( + <> + + + {stream.problem_count} issue{stream.problem_count > 1 ? "s" : ""} + + + )} +
+
+ ); + } + return (
{/* Card header */} @@ -91,6 +193,37 @@ export function StreamCard({ stream, canRemove, onRemove }: StreamCardProps) { ); } +// --------------------------------------------------------------------------- +// Helper — pick the aggregation job relevant to a specific stream +// --------------------------------------------------------------------------- + +/** + * Returns the active aggregation job if it involves the given stream, or the + * most-recent job for that stream from recent_jobs. Falls back to the active + * job unconditionally when the stream_id is unavailable (older server). + */ +function mapAggregationForStream( + agg: AggregationSnapshotWS | undefined, + streamId: string, +) { + if (!agg) return null; + const job = agg.active_job; + if (job) { + // If server reports a specific stream_id for the running camera, match it; + // otherwise surface the active job on all standalone streams. + if (!job.current_stream_id || job.current_stream_id === streamId) { + return job; + } + } + // Check recent_jobs for a completed / failed job touching this stream. + for (const rj of agg.recent_jobs) { + if (!rj.current_stream_id || rj.current_stream_id === streamId) { + return rj; + } + } + return null; +} + function Tag({ children }: { children: React.ReactNode }) { return ( diff --git a/src/syncfield/viewer/frontend/src/lib/types.ts b/src/syncfield/viewer/frontend/src/lib/types.ts index 83bf05a..0ee04a4 100644 --- a/src/syncfield/viewer/frontend/src/lib/types.ts +++ b/src/syncfield/viewer/frontend/src/lib/types.ts @@ -2,6 +2,15 @@ // Snapshot types — mirrors the Python SessionSnapshot / StreamSnapshot // --------------------------------------------------------------------------- +export interface StreamCapabilities { + provides_audio_track: boolean; + supports_precise_timestamps: boolean; + is_removable: boolean; + produces_file: boolean; + /** False for standalone-recorder streams (e.g. Insta360 Go3S) that have no live MJPEG feed. */ + live_preview: boolean; +} + export interface StreamSnapshot { id: string; kind: "video" | "audio" | "sensor" | "custom"; @@ -13,6 +22,8 @@ export interface StreamSnapshot { health_count: number; /** Count of non-heartbeat events (warnings/errors/drops). */ problem_count: number; + /** Stream capabilities declared by the adapter. May be absent on older servers. */ + capabilities?: StreamCapabilities; } export interface ChirpInfo { @@ -28,6 +39,30 @@ export interface HealthEntry { detail: string | null; } +// --------------------------------------------------------------------------- +// Aggregation types (Insta360 Go3S) +// --------------------------------------------------------------------------- + +export type AggregationState = "pending" | "running" | "completed" | "failed"; + +export interface AggregationActiveJob { + job_id: string; + episode_id: string; + state: AggregationState; + cameras_total: number; + cameras_done: number; + current_stream_id: string | null; + current_bytes: number; + current_total_bytes: number; + error: string | null; +} + +export interface AggregationSnapshotWS { + active_job: AggregationActiveJob | null; + queue_length: number; + recent_jobs: AggregationActiveJob[]; +} + export interface SessionSnapshot { type: "snapshot"; state: SessionState; @@ -37,6 +72,8 @@ export interface SessionSnapshot { streams: Record; health_log: HealthEntry[]; output_dir: string; + /** Aggregation state for Go3S streams; present when a Go3S adapter is active. */ + aggregation?: AggregationSnapshotWS; } export type SessionState = @@ -86,7 +123,10 @@ export type ControlAction = | "disconnect" | "record" | "stop" - | "cancel"; + | "cancel" + | "retry_aggregation" + | "cancel_aggregation" + | "aggregate_episode"; export interface ControlCommand { action: ControlAction; diff --git a/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo b/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo index e82e28f..0992a59 100644 --- a/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo +++ b/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/components/audio-level-chart.tsx","./src/components/cluster-config-badge.tsx","./src/components/cluster-controls.tsx","./src/components/cluster-discovery-modal.tsx","./src/components/cluster-panel.tsx","./src/components/control-panel.tsx","./src/components/countdown-overlay.tsx","./src/components/discovery-modal.tsx","./src/components/footer.tsx","./src/components/header.tsx","./src/components/health-table.tsx","./src/components/logo.tsx","./src/components/segment-control.tsx","./src/components/sensor-chart.tsx","./src/components/session-clock.tsx","./src/components/stop-result-banner.tsx","./src/components/stream-card.tsx","./src/components/task-selector.tsx","./src/components/video-preview.tsx","./src/components/review/drift-chart.tsx","./src/components/review/episode-card.tsx","./src/components/review/episode-detail.tsx","./src/components/review/episode-list.tsx","./src/components/review/episode-table.tsx","./src/components/review/review-page.tsx","./src/components/review/review-timeline.tsx","./src/components/review/review-video-player.tsx","./src/components/review/sync-button.tsx","./src/components/review/sync-comparison-modal.tsx","./src/components/review/sync-quality-panel.tsx","./src/components/review/waveform-chart.tsx","./src/hooks/use-cluster.ts","./src/hooks/use-discovery.ts","./src/hooks/use-drift-data.ts","./src/hooks/use-episode.ts","./src/hooks/use-episodes.ts","./src/hooks/use-playback.ts","./src/hooks/use-sensor-stream.ts","./src/hooks/use-session.ts","./src/hooks/use-sync.ts","./src/hooks/use-tasks.ts","./src/lib/format.ts","./src/lib/review-types.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/components/audio-level-chart.tsx","./src/components/cluster-config-badge.tsx","./src/components/cluster-controls.tsx","./src/components/cluster-discovery-modal.tsx","./src/components/cluster-panel.tsx","./src/components/control-panel.tsx","./src/components/countdown-overlay.tsx","./src/components/discovery-modal.tsx","./src/components/footer.tsx","./src/components/header.tsx","./src/components/health-table.tsx","./src/components/logo.tsx","./src/components/segment-control.tsx","./src/components/sensor-chart.tsx","./src/components/session-clock.tsx","./src/components/standalone-recorder-panel.tsx","./src/components/stop-result-banner.tsx","./src/components/stream-card.tsx","./src/components/task-selector.tsx","./src/components/video-preview.tsx","./src/components/review/drift-chart.tsx","./src/components/review/episode-card.tsx","./src/components/review/episode-detail.tsx","./src/components/review/episode-list.tsx","./src/components/review/episode-table.tsx","./src/components/review/review-page.tsx","./src/components/review/review-timeline.tsx","./src/components/review/review-video-player.tsx","./src/components/review/sync-button.tsx","./src/components/review/sync-comparison-modal.tsx","./src/components/review/sync-quality-panel.tsx","./src/components/review/waveform-chart.tsx","./src/hooks/use-cluster.ts","./src/hooks/use-discovery.ts","./src/hooks/use-drift-data.ts","./src/hooks/use-episode.ts","./src/hooks/use-episodes.ts","./src/hooks/use-playback.ts","./src/hooks/use-sensor-stream.ts","./src/hooks/use-session.ts","./src/hooks/use-sync.ts","./src/hooks/use-tasks.ts","./src/lib/format.ts","./src/lib/review-types.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.8.3"} \ No newline at end of file diff --git a/src/syncfield/viewer/poller.py b/src/syncfield/viewer/poller.py index fc72a5d..b746d3b 100644 --- a/src/syncfield/viewer/poller.py +++ b/src/syncfield/viewer/poller.py @@ -206,6 +206,7 @@ def _build_snapshot(self) -> SessionSnapshot: latest_frame=latest_frame, plot_points=plot_points, health_count=len(buffer._health), + live_preview=getattr(stream.capabilities, "live_preview", True), ) # Merge health events into a session-wide, time-sorted log. diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py index e0f69e2..9ba5779 100644 --- a/src/syncfield/viewer/server.py +++ b/src/syncfield/viewer/server.py @@ -92,6 +92,11 @@ def snapshot_to_dict(snapshot: SessionSnapshot) -> Dict[str, Any]: "produces_file": s.produces_file, "health_count": s.health_count, "problem_count": problem_count_by_stream.get(sid, 0), + "capabilities": { + "live_preview": getattr(s, "live_preview", True), + "provides_audio_track": s.provides_audio_track, + "produces_file": s.produces_file, + }, } health_log: List[Dict[str, Any]] = [] @@ -1745,6 +1750,16 @@ async def _handle_command(self, raw: str) -> None: "error": f"Cancel failed: {exc}", "streams": {}, }) + elif action in ("retry_aggregation", "cancel_aggregation", "aggregate_episode"): + # Route aggregation control commands through the T14 dispatcher. + # The payload uses the same key names expected by handle_control_command + # (it reads "command" from the dict), so we normalise here. + agg_payload = {**msg, "command": action} + result = await asyncio.to_thread( + handle_control_command, self._session, agg_payload + ) + if not result.get("ok"): + logger.warning("Aggregation command %r failed: %s", action, result.get("error")) else: logger.warning("Unknown action: %s", action) diff --git a/src/syncfield/viewer/state.py b/src/syncfield/viewer/state.py index 4452e1d..ab58ead 100644 --- a/src/syncfield/viewer/state.py +++ b/src/syncfield/viewer/state.py @@ -80,6 +80,7 @@ class StreamSnapshot: latest_frame: Any # numpy array or None — kept as Any so numpy is optional plot_points: Dict[str, Tuple[List[float], List[float]]] health_count: int + live_preview: bool = True # --------------------------------------------------------------------------- From 9146e1ccd1269f0e49adb4b84300059237df1b27 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:38:58 -0700 Subject: [PATCH 16/28] feat(viewer): add aggregation status bar and episode badges Implements T16: mounts a persistent AggregationStatusBar below the stop-result banner in RecordView (running = animated progress bar + bytes, failed = Retry button). Adds AggregationBadge to EpisodeTable and EpisodeCard in the review mode, sourced from the live WS aggregation snapshot (active_job + recent_jobs). Co-Authored-By: Claude Sonnet 4.6 --- src/syncfield/viewer/frontend/src/App.tsx | 10 + .../src/components/aggregation-status-bar.tsx | 226 ++++++++++++++++++ .../src/components/review/episode-card.tsx | 28 ++- .../src/components/review/episode-list.tsx | 11 +- .../src/components/review/episode-table.tsx | 40 +++- .../src/components/review/review-page.tsx | 9 +- .../viewer/frontend/tsconfig.tsbuildinfo | 2 +- 7 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 src/syncfield/viewer/frontend/src/components/aggregation-status-bar.tsx diff --git a/src/syncfield/viewer/frontend/src/App.tsx b/src/syncfield/viewer/frontend/src/App.tsx index e587515..9a15a22 100644 --- a/src/syncfield/viewer/frontend/src/App.tsx +++ b/src/syncfield/viewer/frontend/src/App.tsx @@ -17,6 +17,10 @@ import { TaskSelector } from "@/components/task-selector"; import { useTasks } from "@/hooks/use-tasks"; import { ReviewPage } from "@/components/review/review-page"; import type { ViewMode } from "@/components/segment-control"; +import { + AggregationStatusBar, + mapActiveAggregation, +} from "@/components/aggregation-status-bar"; // --------------------------------------------------------------------------- // URL-based routing: /record and /review @@ -129,6 +133,12 @@ function RecordView({ )} + {/* Aggregation status bar — visible only when a job is running or failed */} + sendCommand("retry_aggregation", { job_id: jobId })} + /> +
{streamList.length > 0 ? ( diff --git a/src/syncfield/viewer/frontend/src/components/aggregation-status-bar.tsx b/src/syncfield/viewer/frontend/src/components/aggregation-status-bar.tsx new file mode 100644 index 0000000..9137def --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/aggregation-status-bar.tsx @@ -0,0 +1,226 @@ +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AggregationActiveDisplay { + jobId: string; + episodeId: string; + state: "running" | "failed"; + currentStreamId: string | null; + currentBytes: number; + totalBytes: number; + camerasDone: number; + camerasTotal: number; +} + +interface AggregationStatusBarProps { + active: AggregationActiveDisplay | null; + onRetry: (jobId: string) => void; + onViewDetails?: (episodeId: string) => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function AggregationStatusBar({ + active, + onRetry, + onViewDetails, +}: AggregationStatusBarProps) { + if (!active) return null; + + const pct = active.totalBytes + ? Math.round((active.currentBytes / active.totalBytes) * 100) + : 0; + + if (active.state === "failed") { + return ( +
+
+
+
+ {onViewDetails && ( + + )} + +
+
+ ); + } + + // Running state + return ( +
+ {/* Animated dot */} +
diff --git a/src/syncfield/viewer/frontend/src/components/review/episode-list.tsx b/src/syncfield/viewer/frontend/src/components/review/episode-list.tsx index f2e4e8e..76d21f7 100644 --- a/src/syncfield/viewer/frontend/src/components/review/episode-list.tsx +++ b/src/syncfield/viewer/frontend/src/components/review/episode-list.tsx @@ -3,14 +3,16 @@ import { useEpisodes } from "@/hooks/use-episodes"; import { cn } from "@/lib/utils"; import { EpisodeCard } from "./episode-card"; import { EpisodeTable } from "./episode-table"; +import type { AggregationSnapshotWS } from "@/lib/types"; type ListViewMode = "grid" | "table"; interface EpisodeListProps { onSelect: (episodeId: string) => void; + aggregation?: AggregationSnapshotWS; } -export function EpisodeList({ onSelect }: EpisodeListProps) { +export function EpisodeList({ onSelect, aggregation }: EpisodeListProps) { const { episodes, isLoading, error, refresh } = useEpisodes(); const [viewMode, setViewMode] = useState("table"); @@ -86,11 +88,16 @@ export function EpisodeList({ onSelect }: EpisodeListProps) { key={ep.id} episode={ep} onClick={() => onSelect(ep.id)} + aggregation={aggregation} /> ))}
) : ( - + )}
diff --git a/src/syncfield/viewer/frontend/src/components/review/episode-table.tsx b/src/syncfield/viewer/frontend/src/components/review/episode-table.tsx index 0d537d6..e705bda 100644 --- a/src/syncfield/viewer/frontend/src/components/review/episode-table.tsx +++ b/src/syncfield/viewer/frontend/src/components/review/episode-table.tsx @@ -1,12 +1,39 @@ import type { EpisodeSummary } from "@/lib/review-types"; +import type { AggregationSnapshotWS } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { AggregationBadge } from "@/components/aggregation-status-bar"; interface EpisodeTableProps { episodes: EpisodeSummary[]; onSelect: (id: string) => void; + aggregation?: AggregationSnapshotWS; } -export function EpisodeTable({ episodes, onSelect }: EpisodeTableProps) { +/** Derive aggregation state + percent for a given episode from the WS snapshot. */ +function getAggState( + episodeId: string, + aggregation: AggregationSnapshotWS | undefined, +): { state: string | undefined; percent: number | undefined } { + if (!aggregation) return { state: undefined, percent: undefined }; + + const active = aggregation.active_job; + if (active && active.episode_id === episodeId) { + const pct = + active.current_total_bytes > 0 + ? Math.round((active.current_bytes / active.current_total_bytes) * 100) + : 0; + return { state: active.state, percent: pct }; + } + + const recent = aggregation.recent_jobs.find( + (j) => j.episode_id === episodeId, + ); + if (recent) return { state: recent.state, percent: undefined }; + + return { state: undefined, percent: undefined }; +} + +export function EpisodeTable({ episodes, onSelect, aggregation }: EpisodeTableProps) { return (
@@ -18,6 +45,9 @@ export function EpisodeTable({ episodes, onSelect }: EpisodeTableProps) { + {aggregation && ( + + )} @@ -46,6 +76,14 @@ export function EpisodeTable({ episodes, onSelect }: EpisodeTableProps) { )} + {aggregation && (() => { + const { state, percent } = getAggState(ep.id, aggregation); + return ( + + ); + })()} ))} diff --git a/src/syncfield/viewer/frontend/src/components/review/review-page.tsx b/src/syncfield/viewer/frontend/src/components/review/review-page.tsx index 2baf537..15a72fd 100644 --- a/src/syncfield/viewer/frontend/src/components/review/review-page.tsx +++ b/src/syncfield/viewer/frontend/src/components/review/review-page.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { EpisodeList } from "./episode-list"; import { EpisodeDetail } from "./episode-detail"; +import { useSession } from "@/hooks/use-session"; /** * Review mode — browse episodes and analyze sync quality. @@ -13,6 +14,7 @@ import { EpisodeDetail } from "./episode-detail"; * direct links work. */ export function ReviewPage() { + const { snapshot } = useSession(); const [episodeId, setEpisodeId] = useState( getEpisodeIdFromUrl, ); @@ -38,7 +40,12 @@ export function ReviewPage() { return ; } - return ; + return ( + + ); } function getEpisodeIdFromUrl(): string | null { diff --git a/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo b/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo index 0992a59..d14605a 100644 --- a/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo +++ b/src/syncfield/viewer/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/components/audio-level-chart.tsx","./src/components/cluster-config-badge.tsx","./src/components/cluster-controls.tsx","./src/components/cluster-discovery-modal.tsx","./src/components/cluster-panel.tsx","./src/components/control-panel.tsx","./src/components/countdown-overlay.tsx","./src/components/discovery-modal.tsx","./src/components/footer.tsx","./src/components/header.tsx","./src/components/health-table.tsx","./src/components/logo.tsx","./src/components/segment-control.tsx","./src/components/sensor-chart.tsx","./src/components/session-clock.tsx","./src/components/standalone-recorder-panel.tsx","./src/components/stop-result-banner.tsx","./src/components/stream-card.tsx","./src/components/task-selector.tsx","./src/components/video-preview.tsx","./src/components/review/drift-chart.tsx","./src/components/review/episode-card.tsx","./src/components/review/episode-detail.tsx","./src/components/review/episode-list.tsx","./src/components/review/episode-table.tsx","./src/components/review/review-page.tsx","./src/components/review/review-timeline.tsx","./src/components/review/review-video-player.tsx","./src/components/review/sync-button.tsx","./src/components/review/sync-comparison-modal.tsx","./src/components/review/sync-quality-panel.tsx","./src/components/review/waveform-chart.tsx","./src/hooks/use-cluster.ts","./src/hooks/use-discovery.ts","./src/hooks/use-drift-data.ts","./src/hooks/use-episode.ts","./src/hooks/use-episodes.ts","./src/hooks/use-playback.ts","./src/hooks/use-sensor-stream.ts","./src/hooks/use-session.ts","./src/hooks/use-sync.ts","./src/hooks/use-tasks.ts","./src/lib/format.ts","./src/lib/review-types.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/components/aggregation-status-bar.tsx","./src/components/audio-level-chart.tsx","./src/components/cluster-config-badge.tsx","./src/components/cluster-controls.tsx","./src/components/cluster-discovery-modal.tsx","./src/components/cluster-panel.tsx","./src/components/control-panel.tsx","./src/components/countdown-overlay.tsx","./src/components/discovery-modal.tsx","./src/components/footer.tsx","./src/components/header.tsx","./src/components/health-table.tsx","./src/components/logo.tsx","./src/components/segment-control.tsx","./src/components/sensor-chart.tsx","./src/components/session-clock.tsx","./src/components/standalone-recorder-panel.tsx","./src/components/stop-result-banner.tsx","./src/components/stream-card.tsx","./src/components/task-selector.tsx","./src/components/video-preview.tsx","./src/components/review/drift-chart.tsx","./src/components/review/episode-card.tsx","./src/components/review/episode-detail.tsx","./src/components/review/episode-list.tsx","./src/components/review/episode-table.tsx","./src/components/review/review-page.tsx","./src/components/review/review-timeline.tsx","./src/components/review/review-video-player.tsx","./src/components/review/sync-button.tsx","./src/components/review/sync-comparison-modal.tsx","./src/components/review/sync-quality-panel.tsx","./src/components/review/waveform-chart.tsx","./src/hooks/use-cluster.ts","./src/hooks/use-discovery.ts","./src/hooks/use-drift-data.ts","./src/hooks/use-episode.ts","./src/hooks/use-episodes.ts","./src/hooks/use-playback.ts","./src/hooks/use-sensor-stream.ts","./src/hooks/use-session.ts","./src/hooks/use-sync.ts","./src/hooks/use-tasks.ts","./src/lib/format.ts","./src/lib/review-types.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.8.3"} \ No newline at end of file From 366467eba7752dd6a4ec796042fa63a4cfd2cdef Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:48:43 -0700 Subject: [PATCH 17/28] feat(viewer): recognize Go3S in discovery modal Add `add_go3s_stream` command to `handle_control_command` with a `_next_default_id` helper that auto-increments the stream id. Route the command through `_handle_command` (WS action dispatcher). Extend the discovery modal with `isGo3SDevice` name-pattern detection and `deviceTypeLabel` to render "Insta360 Go3S" instead of the raw adapter string for devices whose BLE name contains "go 3" or "go3". Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/insta360_go3s/stream.py | 42 +++++++++++++++++++ .../src/components/discovery-modal.tsx | 21 +++++++++- src/syncfield/viewer/server.py | 35 +++++++++++++++- .../unit/test_viewer_aggregation_commands.py | 26 +++++++++++- 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/syncfield/adapters/insta360_go3s/stream.py b/src/syncfield/adapters/insta360_go3s/stream.py index 6b0b3de..277ed91 100644 --- a/src/syncfield/adapters/insta360_go3s/stream.py +++ b/src/syncfield/adapters/insta360_go3s/stream.py @@ -124,6 +124,9 @@ class Go3SStream(StreamBase): wifi_password: Camera AP password (Insta360 default is ``"88888888"``). """ + _discovery_kind = "video" + _discovery_adapter_type = "insta360_go3s" + def __init__( self, stream_id: str, @@ -262,6 +265,45 @@ def _derive_ssid_from_address(address: str) -> str: suffix = address.replace(":", "").upper()[-12:] return f"Go3S-{suffix}.OSC" + @classmethod + def discover(cls, *, timeout: float = 5.0) -> list: + """Enumerate Go3S cameras currently advertising over BLE. + + Filters by case-insensitive ``"go 3"`` / ``"go3"`` substring on the + advertised name. Each result has ``construct_kwargs`` pre-populated + with the BLE address so the discovery modal can build a working + :class:`Go3SStream` without further user input. + """ + from syncfield.discovery import DiscoveredDevice + from syncfield.discovery._ble import scan_peripherals + + peripherals = scan_peripherals(timeout=timeout) + results = [] + for peripheral in peripherals: + name = (getattr(peripheral, "name", None) or "").strip() + lowered = name.lower() + if "go 3" not in lowered and "go3" not in lowered: + continue + address = getattr(peripheral, "address", None) or "" + results.append( + DiscoveredDevice( + adapter_type="insta360_go3s", + adapter_cls=cls, + kind="video", + display_name=name or "Insta360 Go3S", + description=( + f"Insta360 Go3S · {address[:8]}…" + if address + else "Insta360 Go3S" + ), + device_id=address or name, + construct_kwargs={ + "ble_address": address, + }, + ) + ) + return results + def _run_async(self, coro) -> None: """Bridge sync Stream API to the async BLE helper. diff --git a/src/syncfield/viewer/frontend/src/components/discovery-modal.tsx b/src/syncfield/viewer/frontend/src/components/discovery-modal.tsx index a06e676..46ab8b9 100644 --- a/src/syncfield/viewer/frontend/src/components/discovery-modal.tsx +++ b/src/syncfield/viewer/frontend/src/components/discovery-modal.tsx @@ -2,6 +2,25 @@ import { useEffect, useState } from "react"; import type { DiscoveredDevice } from "@/lib/types"; import { cn } from "@/lib/utils"; +/** + * Returns true when the BLE device name matches the Insta360 Go 3S pattern. + * Matches names like "Insta360 Go 3S", "Go 3S *", or "go3s_*". + */ +function isGo3SDevice(name: string | undefined): boolean { + if (!name) return false; + const lower = name.toLowerCase(); + return lower.includes("go 3") || lower.includes("go3"); +} + +/** + * Human-readable device type label derived from the discovered device's + * adapter / name. Falls back to the raw adapter string for unknown types. + */ +function deviceTypeLabel(device: DiscoveredDevice): string { + if (isGo3SDevice(device.name)) return "Insta360 Go3S"; + return device.adapter; +} + interface DiscoveryModalProps { isOpen: boolean; onClose: () => void; @@ -135,7 +154,7 @@ export function DiscoveryModal({ {device.name}
- {device.adapter} · {device.kind} + {deviceTypeLabel(device)} · {device.kind}
diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py index 9ba5779..e10f117 100644 --- a/src/syncfield/viewer/server.py +++ b/src/syncfield/viewer/server.py @@ -276,11 +276,21 @@ def on_progress(progress) -> None: # --------------------------------------------------------------------------- +def _next_default_id(orchestrator: "SessionOrchestrator", prefix: str) -> str: + """Return the next available stream id ``_N`` not already in use.""" + existing = {s.id for s in orchestrator._streams.values()} + n = 1 + while f"{prefix}_{n}" in existing: + n += 1 + return f"{prefix}_{n}" + + def handle_control_command(orchestrator: "SessionOrchestrator", payload: dict) -> dict: """Dispatch an aggregation control command from a WebSocket client. - Handles the three aggregation commands introduced in T14. All other - (legacy) commands continue to be handled inside ``_handle_command``. + Handles the three aggregation commands introduced in T14, plus the T17 + ``add_go3s_stream`` command. All other (legacy) commands continue to be + handled inside ``_handle_command``. Returns a ``{"ok": True}`` dict on success, or ``{"ok": False, "error": ""}`` on failure — including @@ -297,6 +307,19 @@ def handle_control_command(orchestrator: "SessionOrchestrator", payload: dict) - if cmd == "cancel_aggregation": orchestrator.cancel_aggregation(payload["job_id"]) return {"ok": True} + if cmd == "add_go3s_stream": + try: + from syncfield.adapters.insta360_go3s import Go3SStream + except ImportError as e: + return {"ok": False, "error": f"Go3S adapter not available: {e}"} + stream_id = payload.get("stream_id") or _next_default_id(orchestrator, "go3s_cam") + stream = Go3SStream( + stream_id=stream_id, + ble_address=payload["address"], + output_dir=orchestrator.output_dir, + ) + orchestrator.add(stream) + return {"ok": True, "stream_id": stream.id} return {"ok": False, "error": f"unknown command: {cmd}"} except Exception as exc: return {"ok": False, "error": str(exc)} @@ -1760,6 +1783,14 @@ async def _handle_command(self, raw: str) -> None: ) if not result.get("ok"): logger.warning("Aggregation command %r failed: %s", action, result.get("error")) + elif action == "add_go3s_stream": + # Route Go3S stream-add command through the T17 dispatcher. + go3s_payload = {**msg, "command": action} + result = await asyncio.to_thread( + handle_control_command, self._session, go3s_payload + ) + if not result.get("ok"): + logger.warning("add_go3s_stream failed: %s", result.get("error")) else: logger.warning("Unknown action: %s", action) diff --git a/tests/unit/test_viewer_aggregation_commands.py b/tests/unit/test_viewer_aggregation_commands.py index 81a5284..f8c9a60 100644 --- a/tests/unit/test_viewer_aggregation_commands.py +++ b/tests/unit/test_viewer_aggregation_commands.py @@ -1,4 +1,5 @@ -from unittest.mock import MagicMock +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest @@ -92,3 +93,26 @@ def test_orchestrator_aggregate_episode_finds_pending_job(): ): session.aggregate_episode("ep_test") fake_queue.enqueue.assert_called_once() + + +@patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera") +def test_add_go3s_stream_creates_stream_and_adds_to_orchestrator(_mock_cam, tmp_path): + """add_go3s_stream creates a Go3SStream with the given address and adds it.""" + from syncfield.adapters.insta360_go3s import Go3SStream + from syncfield.orchestrator import SessionOrchestrator + from syncfield.viewer.server import handle_control_command + + session = SessionOrchestrator(host_id="mac", output_dir=tmp_path) + result = handle_control_command( + session, + { + "command": "add_go3s_stream", + "address": "AA:BB:CC:DD:EE:FF", + }, + ) + assert result["ok"] is True + assert result["stream_id"].startswith("go3s_cam_") + # Verify the stream was actually registered + added = session._streams[result["stream_id"]] + assert isinstance(added, Go3SStream) + assert added._ble_address == "AA:BB:CC:DD:EE:FF" From cb76e47ba9eae7bcf49e4a23d2e365df510c7d95 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:51:56 -0700 Subject: [PATCH 18/28] test(go3s): integration test for full record-then-aggregate Co-Authored-By: Claude Opus 4.6 (1M context) --- .../insta360_go3s/test_session_e2e.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/integration/insta360_go3s/test_session_e2e.py diff --git a/tests/integration/insta360_go3s/test_session_e2e.py b/tests/integration/insta360_go3s/test_session_e2e.py new file mode 100644 index 0000000..5a9c5c3 --- /dev/null +++ b/tests/integration/insta360_go3s/test_session_e2e.py @@ -0,0 +1,89 @@ +"""Integration test: full record → enqueue → aggregate → on-disk artifacts.""" +import asyncio +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from syncfield.adapters.insta360_go3s import Go3SStream +from syncfield.adapters.insta360_go3s.aggregation.queue import ( + AggregationDownloader, + AggregationQueue, +) +from syncfield.adapters.insta360_go3s.aggregation.types import AggregationState +from syncfield.adapters.insta360_go3s.ble.camera import CaptureResult +from syncfield.orchestrator import SessionOrchestrator + + +class FakeBleCamera: + def __init__(self, address: str): + self.address = address + + async def connect(self, sync_timeout: float = 2.0, auth_timeout: float = 1.0): + pass + + async def set_video_mode(self): + pass + + async def start_capture(self) -> int: + return 12345 + + async def stop_capture(self) -> CaptureResult: + return CaptureResult(file_path="/DCIM/Camera01/VID_E2E.mp4", ack_host_ns=23456) + + async def disconnect(self): + pass + + +class FakeDownloader(AggregationDownloader): + async def run(self, camera, target_dir, on_chunk): + target = target_dir / camera.local_filename + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"VID_E2E_FAKE_CONTENT") + on_chunk(camera.stream_id, len(b"VID_E2E_FAKE_CONTENT"), len(b"VID_E2E_FAKE_CONTENT")) + + +@pytest.mark.asyncio +async def test_e2e_record_then_aggregate(tmp_path): + queue = AggregationQueue(downloader=FakeDownloader()) + await queue.start() + try: + with ( + patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera", FakeBleCamera), + patch( + "syncfield.adapters.insta360_go3s.stream._global_aggregation_queue", + lambda: queue, + ), + ): + ep_dir = tmp_path / "ep_e2e" + ep_dir.mkdir() + session = SessionOrchestrator(host_id="mac", output_dir=tmp_path) + stream = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=ep_dir, + ) + session.add(stream) + # Stream lifecycle methods call asyncio.run() internally, which + # cannot be called from a running event loop. Run them in a thread. + await asyncio.to_thread(stream.prepare) + await asyncio.to_thread(stream.connect) + await asyncio.to_thread(stream.start_recording, None) # type: ignore[arg-type] + report = await asyncio.to_thread(stream.stop_recording) + await asyncio.to_thread(stream.disconnect) + + assert report.status == "pending_aggregation" + assert stream.pending_aggregation_job is not None + + # Wait for the queue worker to flush the job + for _ in range(50): + if (ep_dir / "overhead.mp4").exists(): + break + await asyncio.sleep(0.1) + assert (ep_dir / "overhead.mp4").exists() + manifest = json.loads((ep_dir / "aggregation.json").read_text()) + assert manifest["state"] == "completed" + assert manifest["cameras"][0]["done"] is True + finally: + await queue.shutdown() From 653cc4177eebc1e2fb4b9568d04df328b72728a1 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:53:23 -0700 Subject: [PATCH 19/28] test(go3s): aggregation does not block subsequent recordings Co-Authored-By: Claude Sonnet 4.6 --- .../test_aggregation_during_recording.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/integration/insta360_go3s/test_aggregation_during_recording.py diff --git a/tests/integration/insta360_go3s/test_aggregation_during_recording.py b/tests/integration/insta360_go3s/test_aggregation_during_recording.py new file mode 100644 index 0000000..f4d99d1 --- /dev/null +++ b/tests/integration/insta360_go3s/test_aggregation_during_recording.py @@ -0,0 +1,95 @@ +"""Integration test: aggregation in-flight does not block subsequent recordings.""" +import asyncio +from pathlib import Path +from unittest.mock import patch + +import pytest + +from syncfield.adapters.insta360_go3s import Go3SStream +from syncfield.adapters.insta360_go3s.aggregation.queue import ( + AggregationDownloader, + AggregationQueue, +) +from syncfield.adapters.insta360_go3s.ble.camera import CaptureResult +from syncfield.orchestrator import SessionOrchestrator + + +class SlowDownloader(AggregationDownloader): + """Holds the WiFi for ~0.5s so a second recording fires while it's busy.""" + + def __init__(self): + self.in_flight = asyncio.Event() + self.may_finish = asyncio.Event() + self.completed: list[str] = [] + + async def run(self, camera, target_dir, on_chunk): + self.in_flight.set() + await self.may_finish.wait() + target = target_dir / camera.local_filename + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"X" * 32) + on_chunk(camera.stream_id, 32, 32) + self.completed.append(camera.stream_id) + + +class FakeBleCamera: + def __init__(self, address): self.address = address + async def connect(self, sync_timeout=2.0, auth_timeout=1.0): pass + async def set_video_mode(self): pass + async def start_capture(self): return 1 + async def stop_capture(self): return CaptureResult(file_path="/DCIM/Camera01/VID.mp4", ack_host_ns=2) + async def disconnect(self): pass + + +@pytest.mark.asyncio +async def test_recording_succeeds_while_aggregation_runs(tmp_path): + downloader = SlowDownloader() + queue = AggregationQueue(downloader=downloader) + await queue.start() + try: + with ( + patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera", FakeBleCamera), + patch( + "syncfield.adapters.insta360_go3s.stream._global_aggregation_queue", + lambda: queue, + ), + ): + session = SessionOrchestrator(host_id="mac", output_dir=tmp_path) + ep1 = tmp_path / "ep1"; ep1.mkdir() + ep2 = tmp_path / "ep2"; ep2.mkdir() + stream = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=ep1, + ) + session.add(stream) + + # Episode 1 — wrap sync calls in to_thread (Go3SStream uses asyncio.run internally) + await asyncio.to_thread(stream.prepare) + await asyncio.to_thread(stream.connect) + await asyncio.to_thread(stream.start_recording, None) # type: ignore[arg-type] + await asyncio.to_thread(stream.stop_recording) # enqueues episode 1 + + # Wait for downloader to be mid-flight + await asyncio.wait_for(downloader.in_flight.wait(), timeout=2.0) + + # Episode 2 — start while episode 1's download is in-flight + stream._output_dir = ep2 # simulate orchestrator advancing episode dir + # Reset the in_flight flag so we can detect the second job's start later + downloader.in_flight.clear() + await asyncio.to_thread(stream.start_recording, None) # type: ignore[arg-type] + report2 = await asyncio.to_thread(stream.stop_recording) + assert report2.status == "pending_aggregation" + + # Now release the slow downloader so both episodes can finish + downloader.may_finish.set() + + for _ in range(80): + if (ep1 / "overhead.mp4").exists() and (ep2 / "overhead.mp4").exists(): + break + await asyncio.sleep(0.05) + assert (ep1 / "overhead.mp4").exists(), "episode 1 should have downloaded" + assert (ep2 / "overhead.mp4").exists(), "episode 2 should have downloaded" + assert "overhead" in downloader.completed, "downloader should have run" + finally: + await queue.shutdown() From cf20fe865be7268c8edb8b4b77fff0eaf1e38ec3 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:54:49 -0700 Subject: [PATCH 20/28] test(go3s): atomic failure preserves originals; retry succeeds Co-Authored-By: Claude Opus 4.6 (1M context) --- .../insta360_go3s/test_atomic_failure.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/integration/insta360_go3s/test_atomic_failure.py diff --git a/tests/integration/insta360_go3s/test_atomic_failure.py b/tests/integration/insta360_go3s/test_atomic_failure.py new file mode 100644 index 0000000..82d1552 --- /dev/null +++ b/tests/integration/insta360_go3s/test_atomic_failure.py @@ -0,0 +1,99 @@ +"""Integration test: atomic failure preserves originals; retry succeeds.""" +import asyncio +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from syncfield.adapters.insta360_go3s import Go3SStream +from syncfield.adapters.insta360_go3s.aggregation.queue import ( + AggregationDownloader, + AggregationQueue, +) +from syncfield.adapters.insta360_go3s.aggregation.types import AggregationState +from syncfield.adapters.insta360_go3s.ble.camera import CaptureResult +from syncfield.orchestrator import SessionOrchestrator + + +class FlakyDownloader(AggregationDownloader): + """Fails the first time, succeeds on retry.""" + + def __init__(self): + self.attempts = 0 + + async def run(self, camera, target_dir, on_chunk): + self.attempts += 1 + if self.attempts == 1: + raise RuntimeError("simulated WiFi switch failure") + target = target_dir / camera.local_filename + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"OK") + on_chunk(camera.stream_id, 2, 2) + + +class FakeBleCamera: + def __init__(self, address): self.address = address + async def connect(self, sync_timeout=2.0, auth_timeout=1.0): pass + async def set_video_mode(self): pass + async def start_capture(self): return 1 + async def stop_capture(self): return CaptureResult(file_path="/DCIM/Camera01/VID.mp4", ack_host_ns=2) + async def disconnect(self): pass + + +@pytest.mark.asyncio +async def test_failure_then_retry(tmp_path): + downloader = FlakyDownloader() + queue = AggregationQueue(downloader=downloader) + await queue.start() + try: + with ( + patch("syncfield.adapters.insta360_go3s.stream.Go3SBLECamera", FakeBleCamera), + patch( + "syncfield.adapters.insta360_go3s.stream._global_aggregation_queue", + lambda: queue, + ), + ): + session = SessionOrchestrator(host_id="mac", output_dir=tmp_path) + ep = tmp_path / "ep_fail"; ep.mkdir() + stream = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=ep, + ) + session.add(stream) + await asyncio.to_thread(stream.prepare) + await asyncio.to_thread(stream.connect) + await asyncio.to_thread(stream.start_recording, None) # type: ignore[arg-type] + await asyncio.to_thread(stream.stop_recording) + assert stream.pending_aggregation_job is not None + job_id = stream.pending_aggregation_job.job_id + + # Wait for first failure to be persisted to aggregation.json + for _ in range(40): + if downloader.attempts >= 1: + break + await asyncio.sleep(0.05) + for _ in range(40): + manifest_path = ep / "aggregation.json" + if manifest_path.exists(): + manifest = json.loads(manifest_path.read_text()) + if manifest["state"] == "failed": + break + await asyncio.sleep(0.05) + assert manifest["state"] == "failed" + assert not (ep / "overhead.mp4").exists(), \ + "no partial file should be left after failure" + + # Retry — invokes queue.retry directly (the orchestrator-level + # retry_aggregation also delegates here, but the queue's retry + # is what actually runs the worker again) + handle = queue.retry(job_id) + final = await handle.wait() + assert final.state == AggregationState.COMPLETED + assert (ep / "overhead.mp4").exists() + # Confirm manifest is updated + manifest = json.loads((ep / "aggregation.json").read_text()) + assert manifest["state"] == "completed" + finally: + await queue.shutdown() From 9d28e74ccecd670487bed4da9d6c04ef9a971df9 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:56:01 -0700 Subject: [PATCH 21/28] docs(examples): add Insta360 Go3S example --- examples/insta360_go3s/README.md | 51 ++++++++++++++++++++++++++++++++ examples/insta360_go3s/record.py | 45 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 examples/insta360_go3s/README.md create mode 100644 examples/insta360_go3s/record.py diff --git a/examples/insta360_go3s/README.md b/examples/insta360_go3s/README.md new file mode 100644 index 0000000..4bbc96f --- /dev/null +++ b/examples/insta360_go3s/README.md @@ -0,0 +1,51 @@ +# Insta360 Go3S example + +Records via BLE trigger; downloads files in a background WiFi aggregation +job after the session ends. + +## One-time setup + +1. **Pair the Go3S** with the laptop using its BLE name (e.g. via the system + Bluetooth pane). After pairing, the BLE address persists. +2. **Discover the BLE address**: + ``` + uv run python -c "import asyncio; from bleak import BleakScanner; \ + print(asyncio.run(BleakScanner.discover()))" + ``` +3. **macOS only**: the first WiFi switch will request Location permission + (required by `networksetup`). Grant once. + +## Run + +``` +uv run python examples/insta360_go3s/record.py \ + --address AA:BB:CC:DD:EE:FF \ + --output ./go3s_output \ + --duration 10 +``` + +After `stop`, the SDK reports `pending_aggregation` and a background worker +switches the host WiFi to the camera AP, downloads the video file, and +restores the previous network. Episode dir contents: + +``` +go3s_output/ +├── overhead.mp4 ← downloaded +├── aggregation.json ← per-episode atomic state +├── manifest.json ← session metadata +└── ... +``` + +## Multihost note + +If you use a `LeaderRole` or `FollowerRole`, the adapter automatically +downgrades the policy to `on_demand` so aggregation does not break lab +WiFi (mDNS) during the session. Trigger aggregation explicitly from the +viewer's "Aggregate now" button after recording wraps. + +## Limitations (v1) + +- No live preview (the camera does not expose one over the BLE/OSC path). +- No Windows WiFi switching (`NotImplementedError`); BLE-only flows still work. +- Per-camera resolution/fps uses the camera's own UI setting. +- Aggregation across multiple Go3S devices is sequential per episode. diff --git a/examples/insta360_go3s/record.py b/examples/insta360_go3s/record.py new file mode 100644 index 0000000..21b7dc4 --- /dev/null +++ b/examples/insta360_go3s/record.py @@ -0,0 +1,45 @@ +"""Single-host recording with one Insta360 Go3S camera. + +Usage: + uv run python examples/insta360_go3s/record.py \\ + --address AA:BB:CC:DD:EE:FF \\ + --output ./go3s_output \\ + --duration 10 +""" +from __future__ import annotations + +import argparse +import time +from pathlib import Path + +import syncfield as sf +from syncfield.adapters.insta360_go3s import Go3SStream + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--address", required=True, help="Go3S BLE address (MAC or CB UUID)") + parser.add_argument("--output", type=Path, default=Path("./go3s_output")) + parser.add_argument("--duration", type=float, default=10.0) + args = parser.parse_args() + + args.output.mkdir(parents=True, exist_ok=True) + + session = sf.SessionOrchestrator(host_id="local", output_dir=args.output) + session.add(Go3SStream( + stream_id="overhead", + ble_address=args.address, + output_dir=args.output, + )) + + print(f"[record] starting session, duration={args.duration}s") + session.start_recording() + time.sleep(args.duration) + report = session.stop_recording() + print(f"[record] stopped; per-stream reports: {report}") + print("[record] aggregation runs in the background; check the viewer or look in", + args.output) + + +if __name__ == "__main__": + main() From 1abbdc667297d0596e289fdf371efdc7bc62c236 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 03:56:48 -0700 Subject: [PATCH 22/28] build: add aiohttp to camera optional extra Adds a 'camera' optional-dependencies entry for the Insta360 Go3S adapter (bleak + aiohttp) and rolls the new package into the 'all' extra. Install with: uv add 'syncfield[camera]'. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 6 ++++++ uv.lock | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e00feb2..f527369 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,12 +53,18 @@ multihost = [ "uvicorn[standard]>=0.24.0", "httpx>=0.25.0", ] +# Insta360 Go3S adapter: BLE trigger via bleak + WiFi/OSC HTTP download via aiohttp. +camera = [ + "bleak>=0.21", + "aiohttp>=3.9", +] all = [ "sounddevice>=0.4.6", "numpy>=1.21", "av>=12.0.0", "Pillow>=10.0.0", "bleak>=0.21", + "aiohttp>=3.9", "depthai>=3.0.0", "fastapi>=0.104.0", "uvicorn[standard]>=0.24.0", diff --git a/uv.lock b/uv.lock index 4b68e78..88a0e97 100644 --- a/uv.lock +++ b/uv.lock @@ -2335,6 +2335,7 @@ source = { editable = "." } [package.optional-dependencies] all = [ + { name = "aiohttp" }, { name = "av", version = "15.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "av", version = "17.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -2363,6 +2364,11 @@ ble = [ { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "bleak", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] +camera = [ + { name = "aiohttp" }, + { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "bleak", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] multihost = [ { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "fastapi", version = "0.135.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -2405,12 +2411,15 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'all'", specifier = ">=3.9" }, + { name = "aiohttp", marker = "extra == 'camera'", specifier = ">=3.9" }, { name = "av", marker = "extra == 'all'", specifier = ">=12.0.0" }, { name = "av", marker = "extra == 'oak'", specifier = ">=12.0.0" }, { name = "av", marker = "extra == 'uvc'", specifier = ">=12.0.0" }, { name = "av", marker = "extra == 'viewer'", specifier = ">=12.0.0" }, { name = "bleak", marker = "extra == 'all'", specifier = ">=0.21" }, { name = "bleak", marker = "extra == 'ble'", specifier = ">=0.21" }, + { name = "bleak", marker = "extra == 'camera'", specifier = ">=0.21" }, { name = "depthai", marker = "extra == 'all'", specifier = ">=3.0.0" }, { name = "depthai", marker = "extra == 'oak'", specifier = ">=3.0.0" }, { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.104.0" }, @@ -2430,7 +2439,7 @@ requires-dist = [ { name = "zeroconf", marker = "extra == 'all'", specifier = ">=0.130" }, { name = "zeroconf", marker = "extra == 'multihost'", specifier = ">=0.130" }, ] -provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "multihost", "all"] +provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "multihost", "camera", "all"] [package.metadata.requires-dev] dev = [ From 4598ca72707a8cdbbfa3201973c1e0340dad6532 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 04:06:21 -0700 Subject: [PATCH 23/28] fix(go3s): reject empty file_path from BLE STOP response Empty string bypassed the prior None guard and produced an AggregationJob with sd_path='' that failed opaquely in the worker. Now raises a clear error at stop_recording() with diagnostic guidance. --- src/syncfield/adapters/insta360_go3s/stream.py | 8 ++++++-- .../adapters/insta360_go3s/test_go3s_stream.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/syncfield/adapters/insta360_go3s/stream.py b/src/syncfield/adapters/insta360_go3s/stream.py index 277ed91..11fe335 100644 --- a/src/syncfield/adapters/insta360_go3s/stream.py +++ b/src/syncfield/adapters/insta360_go3s/stream.py @@ -237,8 +237,12 @@ async def _do_stop(self) -> None: await cam.disconnect() def _build_job(self) -> AggregationJob: - if self._sd_path is None: - raise RuntimeError("stop_recording did not return a file path") + if not self._sd_path: + raise RuntimeError( + "stop_recording did not return a file path; the BLE STOP response " + "did not contain a /DCIM/... entry. Verify the camera is in video " + "mode and reachable." + ) ext = ".mp4" if self._sd_path.lower().endswith(".mp4") else ".insv" camera_spec = AggregationCameraSpec( stream_id=self.id, diff --git a/tests/unit/adapters/insta360_go3s/test_go3s_stream.py b/tests/unit/adapters/insta360_go3s/test_go3s_stream.py index dfa51d0..6d18c47 100644 --- a/tests/unit/adapters/insta360_go3s/test_go3s_stream.py +++ b/tests/unit/adapters/insta360_go3s/test_go3s_stream.py @@ -113,3 +113,18 @@ def test_on_demand_policy_does_not_enqueue(fake_ble, fake_queue, tmp_path): # An ID for manual aggregation later should still be exposed assert s.pending_aggregation_job is not None assert s.pending_aggregation_job.cameras[0].sd_path == "/DCIM/Camera01/VID_FAKE.mp4" + + +def test_stop_recording_raises_when_ble_returns_empty_filepath(fake_ble, fake_queue, tmp_path): + """If BLE STOP doesn't echo a /DCIM/... path, surface a clear error.""" + fake_ble.stop_capture.return_value = CaptureResult(file_path="", ack_host_ns=0) + s = Go3SStream( + stream_id="overhead", + ble_address="AA:BB:CC:DD:EE:FF", + output_dir=tmp_path, + ) + s.prepare() + s.connect() + s.start_recording(session_clock=MagicMock()) + with pytest.raises(RuntimeError, match="did not return a file path"): + s.stop_recording() From ae101b588fe2346dd7713e631f79e5dbce098138 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 04:07:11 -0700 Subject: [PATCH 24/28] fix(go3s): wire recover_from_disk into singleton init for crash recovery The aggregation queue has had crash-recovery support since T08, but no production callsite invoked it. Now scan SYNCFIELD_GO3S_RECOVERY_ROOT (default: cwd) at singleton init and re-enqueue any leftover PENDING/ RUNNING aggregation jobs from prior runs. --- .../adapters/insta360_go3s/stream.py | 27 +++++++ .../insta360_go3s/test_go3s_stream.py | 76 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/syncfield/adapters/insta360_go3s/stream.py b/src/syncfield/adapters/insta360_go3s/stream.py index 11fe335..04225c3 100644 --- a/src/syncfield/adapters/insta360_go3s/stream.py +++ b/src/syncfield/adapters/insta360_go3s/stream.py @@ -2,12 +2,16 @@ from __future__ import annotations import asyncio +import logging +import os import threading import time from concurrent.futures import Future from pathlib import Path from typing import Literal, Optional +logger = logging.getLogger(__name__) + from syncfield.clock import SessionClock from syncfield.stream import StreamBase from syncfield.types import ( @@ -84,6 +88,23 @@ def _run_loop() -> None: _QUEUE_LOOP = loop _QUEUE_THREAD = thread _QUEUE = queue + + # Recover any pending aggregation jobs from prior runs. + # Search root is configurable via SYNCFIELD_GO3S_RECOVERY_ROOT; + # defaults to current working directory. + recovery_root = Path(os.environ.get("SYNCFIELD_GO3S_RECOVERY_ROOT", ".")) + if recovery_root.exists(): + try: + recovered = queue.recover_from_disk(search_root=recovery_root) + for job in recovered: + _enqueue_async_marshalled(queue, job) + if recovered: + logger.info( + "Go3S aggregation: recovered %d pending job(s) from %s", + len(recovered), recovery_root, + ) + except Exception: + logger.exception("Go3S aggregation recovery scan failed") return _QUEUE @@ -91,6 +112,12 @@ async def _enqueue_async(queue: AggregationQueue, job: AggregationJob) -> None: queue.enqueue(job) +def _enqueue_async_marshalled(queue: AggregationQueue, job: AggregationJob) -> None: + """Enqueue from inside the singleton initializer (we know _QUEUE_LOOP is set).""" + fut: Future = asyncio.run_coroutine_threadsafe(_enqueue_async(queue, job), _QUEUE_LOOP) + fut.result(timeout=5.0) + + def _enqueue_on_global_queue(job: AggregationJob) -> None: """Thread-safe enqueue marshaled onto the queue's owned loop. diff --git a/tests/unit/adapters/insta360_go3s/test_go3s_stream.py b/tests/unit/adapters/insta360_go3s/test_go3s_stream.py index 6d18c47..fc3127e 100644 --- a/tests/unit/adapters/insta360_go3s/test_go3s_stream.py +++ b/tests/unit/adapters/insta360_go3s/test_go3s_stream.py @@ -128,3 +128,79 @@ def test_stop_recording_raises_when_ble_returns_empty_filepath(fake_ble, fake_qu s.start_recording(session_clock=MagicMock()) with pytest.raises(RuntimeError, match="did not return a file path"): s.stop_recording() + + +def test_recovery_scan_picks_up_pending_aggregation(tmp_path, monkeypatch): + """A leftover aggregation.json from a prior run is re-enqueued at startup.""" + import json + import time as _time + from syncfield.adapters.insta360_go3s.aggregation.types import ( + AggregationCameraSpec, AggregationJob, AggregationState, + ) + + # Reset the singleton so the test triggers fresh init. + import syncfield.adapters.insta360_go3s.stream as _stream_mod + monkeypatch.setattr(_stream_mod, "_QUEUE", None) + monkeypatch.setattr(_stream_mod, "_QUEUE_LOOP", None) + monkeypatch.setattr(_stream_mod, "_QUEUE_THREAD", None) + monkeypatch.setenv("SYNCFIELD_GO3S_RECOVERY_ROOT", str(tmp_path)) + + # Plant a pending aggregation manifest. + ep_dir = tmp_path / "ep_recover" + ep_dir.mkdir() + job = AggregationJob( + job_id="agg_recover", + episode_id="ep_recover", + episode_dir=ep_dir, + cameras=[AggregationCameraSpec( + stream_id="overhead", + ble_address="AA:BB", + wifi_ssid="Go3S-X.OSC", + wifi_password="88888888", + sd_path="/DCIM/Camera01/X.mp4", + local_filename="overhead.mp4", + size_bytes=0, + )], + state=AggregationState.PENDING, + ) + job.write_manifest() + + # Stub the production downloader so it succeeds without real WiFi. + from syncfield.adapters.insta360_go3s.aggregation.queue import ( + AggregationDownloader, + ) + + class NoOpDownloader(AggregationDownloader): + async def run(self, camera, target_dir, on_chunk): + pass # instant "success" + + monkeypatch.setattr( + _stream_mod, + "Go3SAggregationDownloader", + lambda *args, **kwargs: NoOpDownloader(), + ) + # Stub wifi_switcher_for_platform so init doesn't probe real networks. + monkeypatch.setattr( + _stream_mod, + "wifi_switcher_for_platform", + lambda: MagicMock(), + ) + + from syncfield.adapters.insta360_go3s.stream import _global_aggregation_queue + _global_aggregation_queue() + + # Give the worker a moment to drain the recovered job. + for _ in range(40): + manifest = json.loads((ep_dir / "aggregation.json").read_text()) + if manifest["state"] == "completed": + break + _time.sleep(0.05) + + assert manifest["state"] == "completed", ( + f"recovered job not processed: state={manifest['state']}" + ) + + # Teardown: reset singleton so later tests start fresh if they need it. + monkeypatch.setattr(_stream_mod, "_QUEUE", None) + monkeypatch.setattr(_stream_mod, "_QUEUE_LOOP", None) + monkeypatch.setattr(_stream_mod, "_QUEUE_THREAD", None) From c12941da7ec2032f8286b9bb6173854ecd943c21 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 04:07:28 -0700 Subject: [PATCH 25/28] fix(go3s): replace deprecated asyncio.get_event_loop() with get_running_loop() Both call sites are inside running coroutines, so get_running_loop() is the correct API. Eliminates DeprecationWarning on Python 3.10+ and prevents the RuntimeError that 3.14 will raise. --- src/syncfield/adapters/insta360_go3s/aggregation/queue.py | 4 ++-- src/syncfield/adapters/insta360_go3s/ble/camera.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/syncfield/adapters/insta360_go3s/aggregation/queue.py b/src/syncfield/adapters/insta360_go3s/aggregation/queue.py index 3040d44..39cd15c 100644 --- a/src/syncfield/adapters/insta360_go3s/aggregation/queue.py +++ b/src/syncfield/adapters/insta360_go3s/aggregation/queue.py @@ -307,10 +307,10 @@ async def run( ) async def _wait_for_ap(self) -> None: - deadline = asyncio.get_event_loop().time() + self._wait_for_ap_timeout + deadline = asyncio.get_running_loop().time() + self._wait_for_ap_timeout last_error: Exception | None = None for _ in range(self._ap_probe_attempts): - if asyncio.get_event_loop().time() > deadline: + if asyncio.get_running_loop().time() > deadline: break try: osc = self._osc_factory(self._ap_host) diff --git a/src/syncfield/adapters/insta360_go3s/ble/camera.py b/src/syncfield/adapters/insta360_go3s/ble/camera.py index c652346..dc015c1 100644 --- a/src/syncfield/adapters/insta360_go3s/ble/camera.py +++ b/src/syncfield/adapters/insta360_go3s/ble/camera.py @@ -289,7 +289,7 @@ async def _send_raw( assert self._client is not None, "Not connected" seq = self._next_seq() - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() fut: asyncio.Future = loop.create_future() self._pending_acks[seq] = fut From c9cba3d3503f62ad50518b232aa5dd9f54414c29 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 04:08:03 -0700 Subject: [PATCH 26/28] fix(viewer): scope _recent_agg_jobs to ViewerServer instance + lock Module-level mutable list bled state across instances and was mutated from the aggregation worker thread without synchronization. --- src/syncfield/viewer/server.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py index e10f117..327a7e6 100644 --- a/src/syncfield/viewer/server.py +++ b/src/syncfield/viewer/server.py @@ -23,6 +23,7 @@ import io import json import logging +import threading import time from pathlib import Path from typing import Any, Dict, List, Optional, Set @@ -228,10 +229,6 @@ def _require_leader(orch) -> None: # Aggregation listener wiring # --------------------------------------------------------------------------- -# Rolling window of completed/failed jobs (trimmed to 5 most recent). -_recent_agg_jobs: List[Any] = [] - - def _attach_aggregation_listener(server: "ViewerServer") -> None: """Best-effort: subscribe to the global Go3S aggregation queue. @@ -252,13 +249,16 @@ def on_progress(progress) -> None: else: active = None if progress.state in (AggregationState.COMPLETED, AggregationState.FAILED): - _recent_agg_jobs.append(progress) - if len(_recent_agg_jobs) > 5: - del _recent_agg_jobs[: len(_recent_agg_jobs) - 5] + with server._agg_lock: + server._recent_agg_jobs.append(progress) + if len(server._recent_agg_jobs) > 5: + del server._recent_agg_jobs[: len(server._recent_agg_jobs) - 5] + with server._agg_lock: + recent = list(server._recent_agg_jobs) server._agg_state = AggregationSnapshot( active_job=active, queue_length=0, # populated by queue if exposed; staying 0 in v1 - recent_jobs=list(_recent_agg_jobs), + recent_jobs=recent, ) try: @@ -351,6 +351,8 @@ def __init__( self._sync_endpoint = sync_endpoint.rstrip("/") self._ws_clients: Set[WebSocket] = set() self._agg_state: Optional[AggregationSnapshot] = None + self._recent_agg_jobs: list = [] + self._agg_lock = threading.Lock() self.app = FastAPI(title=title, docs_url=None, redoc_url=None) self._setup_middleware() From 1b171c645f8f6c639fac41c62d8a787197b73ab7 Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 04:09:02 -0700 Subject: [PATCH 27/28] fix(viewer): include full StreamCapabilities in WS snapshot Was sending only 3 of 5 fields; the TS StreamCapabilities interface declares all 5. Use to_dict() so future consumers don't see undefined for supports_precise_timestamps / is_removable. --- src/syncfield/viewer/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py index 327a7e6..82b5dc6 100644 --- a/src/syncfield/viewer/server.py +++ b/src/syncfield/viewer/server.py @@ -97,6 +97,8 @@ def snapshot_to_dict(snapshot: SessionSnapshot) -> Dict[str, Any]: "live_preview": getattr(s, "live_preview", True), "provides_audio_track": s.provides_audio_track, "produces_file": s.produces_file, + "supports_precise_timestamps": getattr(s, "supports_precise_timestamps", False), + "is_removable": getattr(s, "is_removable", False), }, } From e4a65b0c8253e8c9d70e6f7e15ebd114c23f830b Mon Sep 17 00:00:00 2001 From: styu12 Date: Tue, 14 Apr 2026 04:09:30 -0700 Subject: [PATCH 28/28] docs(spec): remove between_sessions policy from v1; document as future work --- .../specs/2026-04-14-insta360-go3s-adapter-design.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-04-14-insta360-go3s-adapter-design.md b/docs/superpowers/specs/2026-04-14-insta360-go3s-adapter-design.md index 611355a..4ac5ed0 100644 --- a/docs/superpowers/specs/2026-04-14-insta360-go3s-adapter-design.md +++ b/docs/superpowers/specs/2026-04-14-insta360-go3s-adapter-design.md @@ -92,7 +92,7 @@ Go3SStream( stream_id: str, ble_address: str, # BLE MAC or CoreBluetooth UUID output_dir: Path, # episode dir provided by orchestrator - aggregation_policy: Literal["eager", "on_demand", "between_sessions"] = "eager", + aggregation_policy: Literal["eager", "on_demand"] = "eager", video_mode: Literal["video"] = "video", ) ``` @@ -221,7 +221,8 @@ PENDING ──► RUNNING ──► COMPLETED `aggregation_policy`: - `eager` (default single-host): on `stop_recording()`, auto-enqueue. Worker starts immediately. - `on_demand` (auto-forced in multihost leader/follower): do not enqueue; viewer "Aggregate now" button triggers enqueue. -- `between_sessions`: enqueue on `stop_recording()` but worker only runs when orchestrator state == `IDLE`. If a new recording starts while the worker is mid-download, the worker finishes the in-flight file (BLE trigger for the new recording is independent — no interference), then pauses before the next file until the orchestrator returns to `IDLE`. + +v1 supports `eager` (default) and `on_demand`. Future work: `between_sessions` policy that defers aggregation until the orchestrator returns to IDLE state — would require orchestrator-level coordination. **Multihost autodetection**: on `session.add(Go3SStream(...))`, if session's role is `LeaderRole` or `FollowerRole`, orchestrator downgrades `eager` → `on_demand` with a health event explaining why. @@ -300,7 +301,7 @@ New commands: - `test_osc_client.py` — mocked aiohttp responses for `/osc/info`, `listFiles`, downloads including partial/truncated streams. - `test_wifi_switcher.py` — each platform impl unit tested with `subprocess.run` mocked; factory selection test per `sys.platform`. - `test_aggregation_queue.py` — job lifecycle, retry, crash recovery from `aggregation.json`, listener notifications. -- `test_go3s_stream.py` — lifecycle with fake BLE client, policy resolution (eager / on_demand / between_sessions), multihost auto-downgrade. +- `test_go3s_stream.py` — lifecycle with fake BLE client, policy resolution (eager / on_demand), multihost auto-downgrade. ### Integration tests - `tests/integration/test_go3s_session_e2e.py` — full session with mocked BLE + mocked OSC server + temp WiFi switcher; verifies episode dir contents, manifest entries, aggregation.json, and finalization reports.
Host Streams SyncAggregation
+ +