diff --git a/pyproject.toml b/pyproject.toml index 8b13081..02e6bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "hatchling.build" [project] name = "syncfield" -version = "0.1.1" -description = "Lightweight timestamp capture SDK for SyncField multi-stream synchronization" +version = "0.2.0" +description = "Multi-modal capture orchestration framework with precision sync for Physical AI data collection" readme = "README.md" license = "Apache-2.0" requires-python = ">=3.9" authors = [{ name = "OpenGraph Labs" }] -keywords = ["synchronization", "timestamp", "multi-camera", "robotics", "data-collection"] +keywords = ["synchronization", "timestamp", "multi-camera", "robotics", "data-collection", "physical-ai"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -23,6 +23,22 @@ classifiers = [ "Topic :: Scientific/Engineering", ] +[project.optional-dependencies] +# sounddevice.play() internally requires numpy even when the caller hands it +# Python lists, so numpy is part of the audio extra. +audio = [ + "sounddevice>=0.4.6", + "numpy>=1.21", +] +uvc = ["opencv-python>=4.5"] +ble = ["bleak>=0.21"] +all = [ + "sounddevice>=0.4.6", + "numpy>=1.21", + "opencv-python>=4.5", + "bleak>=0.21", +] + [project.urls] Homepage = "https://opengraphlabs.com/" Repository = "https://github.com/OpenGraphLabs/syncfield-python" @@ -34,8 +50,12 @@ packages = ["src/syncfield"] [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "hardware: tests that require physical hardware (cameras, BLE devices)", +] [dependency-groups] dev = [ "pytest>=8.4.2", + "pytest-mock>=3.12.0", ] diff --git a/scripts/e2e_chirp_check.py b/scripts/e2e_chirp_check.py new file mode 100644 index 0000000..44bb3ed --- /dev/null +++ b/scripts/e2e_chirp_check.py @@ -0,0 +1,265 @@ +"""End-to-end chirp verification against real audio hardware. + +Run this on a machine with working audio output (not headless CI!) to prove +that: + +1. ``create_default_player()`` picks the sounddevice backend. +2. ``SessionOrchestrator.start()`` actually plays the start chirp. +3. ``SessionOrchestrator.stop()`` plays the stop chirp before stopping streams. +4. ``sync_point.json`` carries the expected ``chirp_start_ns`` / ``chirp_stop_ns`` + / ``chirp_spec`` fields. +5. A real recording of the system audio would capture the chirps (we verify + this by capturing audio *on this same host* via an ``InputStream`` during + the session and running a cross-correlation against the generated chirp + samples). + +Usage:: + + uv sync --extra audio # make sure sounddevice is installed + uv run python scripts/e2e_chirp_check.py + +The script returns exit code 0 on success, 1 if any check fails. +""" + +from __future__ import annotations + +import json +import math +import sys +import tempfile +import threading +import time +from pathlib import Path + +import sounddevice as sd # type: ignore[import-not-found] + +import syncfield as sf +from syncfield.testing import FakeStream +from syncfield.tone import ( + SilentChirpPlayer, + SoundDeviceChirpPlayer, + create_default_player, + generate_chirp_samples, +) + + +# --------------------------------------------------------------------------- +# Check 1: default player backend +# --------------------------------------------------------------------------- + + +def check_default_player_uses_sounddevice() -> None: + player = create_default_player() + if not isinstance(player, SoundDeviceChirpPlayer): + raise SystemExit( + f"FAIL: create_default_player() returned {type(player).__name__}, " + "expected SoundDeviceChirpPlayer. Is sounddevice installed?" + ) + if isinstance(player, SilentChirpPlayer): + raise SystemExit("FAIL: got a SilentChirpPlayer instead of sounddevice backend.") + print("[1/5] OK — create_default_player() returned SoundDeviceChirpPlayer") + + +# --------------------------------------------------------------------------- +# Check 2/3: real session plays both chirps through sounddevice +# --------------------------------------------------------------------------- + + +def run_session_with_recording(output_dir: Path) -> tuple[dict, list[float], int]: + """Run a short real session; return sync_point, recorded mono samples, sample_rate. + + We spin up a sounddevice InputStream on a background worker to capture the + local mic for the duration of the session. If no mic is available the + function still returns an empty list for the samples — the other checks + don't depend on it. + """ + sample_rate = 44100 + recorded: list[float] = [] + stop_input = threading.Event() + + def _capture() -> None: + try: + with sd.InputStream( + samplerate=sample_rate, + channels=1, + dtype="float32", + ) as stream: + while not stop_input.is_set(): + block, _ = stream.read(1024) + recorded.extend(float(x) for x in block[:, 0]) + except Exception as exc: + print(f" (mic capture skipped: {exc})") + + capture_thread = threading.Thread(target=_capture, daemon=True) + capture_thread.start() + # Let the input stream settle before the session starts. + time.sleep(0.2) + + session = sf.SessionOrchestrator( + host_id="e2e_chirp_check", + output_dir=output_dir, + sync_tone=sf.SyncToneConfig.default(), + ) + # Declaring audio capability triggers chirp eligibility. + session.add(FakeStream("mic_fake", provides_audio_track=True)) + + session.start() + # Simulate a brief "recording" interval between chirps. + time.sleep(0.3) + report = session.stop() + + # Allow the stop chirp tail to reach the input stream before closing it. + time.sleep(0.3) + stop_input.set() + capture_thread.join(timeout=2.0) + + sync_point = json.loads((output_dir / "sync_point.json").read_text()) + assert report.chirp_start_ns is not None + assert report.chirp_stop_ns is not None + return sync_point, recorded, sample_rate + + +def check_session_writes_chirp_fields(sync_point: dict) -> None: + for field in ("chirp_start_ns", "chirp_stop_ns", "chirp_spec"): + if field not in sync_point: + raise SystemExit( + f"FAIL: sync_point.json is missing {field!r}: {sync_point}" + ) + start_ns = sync_point["chirp_start_ns"] + stop_ns = sync_point["chirp_stop_ns"] + if not (stop_ns > start_ns): + raise SystemExit( + f"FAIL: chirp_stop_ns ({stop_ns}) must be > chirp_start_ns ({start_ns})" + ) + spec = sync_point["chirp_spec"] + if spec["from_hz"] != 400 or spec["to_hz"] != 2500: + raise SystemExit(f"FAIL: unexpected default chirp spec: {spec}") + print( + "[2/5] OK — sync_point.json carries chirp_start_ns, chirp_stop_ns, chirp_spec" + ) + print( + f"[3/5] OK — stop_ns - start_ns = {(stop_ns - start_ns) / 1e6:.1f} ms " + "(stop chirp plays after start chirp)" + ) + + +# --------------------------------------------------------------------------- +# Check 4: the chirp is actually audible (high correlation with reference) +# --------------------------------------------------------------------------- + + +def _normalized_xcorr_peak(signal: list[float], reference: list[float]) -> float: + """Return the maximum absolute normalized cross-correlation in [0, 1]. + + Simple O(N*M) implementation — fine for the ~30 000-sample inputs we use + here and avoids adding numpy just for one function. + """ + if not signal or not reference: + return 0.0 + ref_len = len(reference) + ref_energy = math.sqrt(sum(r * r for r in reference)) + if ref_energy == 0.0: + return 0.0 + + best = 0.0 + # Step through the signal in 4-sample increments for speed; that's still + # well below the ~11 sample period of a 4 kHz tone so correlation peaks + # won't be missed. + step = 4 + for start in range(0, len(signal) - ref_len + 1, step): + window = signal[start : start + ref_len] + dot = 0.0 + win_energy = 0.0 + for a, b in zip(window, reference): + dot += a * b + win_energy += a * a + if win_energy == 0.0: + continue + corr = abs(dot) / (math.sqrt(win_energy) * ref_energy) + if corr > best: + best = corr + return best + + +def check_chirp_is_audible( + recorded: list[float], + sample_rate: int, + sync_point: dict, +) -> None: + if not recorded: + print("[4/5] SKIP — no microphone capture available, skipping xcorr check") + return + + spec_dict = sync_point["chirp_spec"] + start_spec = sf.ChirpSpec( + from_hz=spec_dict["from_hz"], + to_hz=spec_dict["to_hz"], + duration_ms=spec_dict["duration_ms"], + amplitude=spec_dict["amplitude"], + envelope_ms=spec_dict["envelope_ms"], + ) + reference = generate_chirp_samples(start_spec, sample_rate=sample_rate) + + peak = _normalized_xcorr_peak(recorded, reference) + print( + f"[4/5] {'OK ' if peak > 0.15 else 'WARN'} — " + f"normalized xcorr peak {peak:.3f} " + f"({'chirp detected in mic capture' if peak > 0.15 else 'weak or absent; check volume/mic'})" + ) + # 0.15 is deliberately loose — a quiet room with speakers a meter away + # from the mic typically gives 0.2–0.5. We only fail on zero/NaN signals. + if peak <= 0.0: + raise SystemExit(f"FAIL: xcorr peak is {peak:.3f} — no correlation with chirp") + + +# --------------------------------------------------------------------------- +# Check 5: chirp eligibility skip path is silent +# --------------------------------------------------------------------------- + + +def check_no_audio_stream_skips_chirp(output_dir: Path) -> None: + session = sf.SessionOrchestrator( + host_id="e2e_chirp_check_no_audio", + output_dir=output_dir, + sync_tone=sf.SyncToneConfig.default(), + ) + session.add(FakeStream("imu_only", provides_audio_track=False)) + session.start() + session.stop() + + sp = json.loads((output_dir / "sync_point.json").read_text()) + if "chirp_start_ns" in sp: + raise SystemExit( + "FAIL: a session without audio-capable streams still wrote chirp fields" + ) + print("[5/5] OK — audio-less session cleanly skips chirp (no chirp fields)") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + print("SyncField E2E chirp check — running on real audio hardware") + print(f" default output device: {sd.query_devices(sd.default.device[1])['name']}") + print() + + check_default_player_uses_sounddevice() + + with tempfile.TemporaryDirectory() as td: + session_dir = Path(td) / "session_audio" + sync_point, recorded, sr = run_session_with_recording(session_dir) + check_session_writes_chirp_fields(sync_point) + check_chirp_is_audible(recorded, sr, sync_point) + + silent_dir = Path(td) / "session_silent" + check_no_audio_stream_skips_chirp(silent_dir) + + print() + print("all checks passed ✓") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/syncfield/__init__.py b/src/syncfield/__init__.py index 609826a..01f9cba 100644 --- a/src/syncfield/__init__.py +++ b/src/syncfield/__init__.py @@ -1,22 +1,59 @@ -"""SyncField — lightweight timestamp capture for multi-stream synchronization. +"""SyncField — capture orchestration framework for multi-modal synchronization. Quick start:: import syncfield as sf + from syncfield.adapters import UVCWebcamStream, JSONLFileStream - session = sf.SyncSession(host_id="rig_01", output_dir="./timestamps") - session.start() + session = sf.SessionOrchestrator( + host_id="rig_01", + output_dir="./data", + ) + session.add(UVCWebcamStream("cam_main", device_index=0, output_dir="./data")) + session.add(JSONLFileStream("sensor_log", file_path="./data/sensor.jsonl")) - frame = camera.read() - session.stamp("cam_left", frame_number=0) + session.start() + # ... recording ... + report = session.stop() - session.stop() +See the :mod:`syncfield.adapters` subpackage for built-in reference adapters +and :mod:`syncfield.testing` for utilities like :class:`~syncfield.testing.FakeStream` +used in unit tests. """ from importlib.metadata import version as _pkg_version -from syncfield.capture import SyncSession -from syncfield.types import ChannelValue, FrameTimestamp, SensorSample, SyncPoint - -__all__ = ["SyncSession", "SyncPoint", "FrameTimestamp", "SensorSample", "ChannelValue"] +from syncfield.clock import SessionClock +from syncfield.orchestrator import SessionOrchestrator +from syncfield.stream import Stream, StreamBase +from syncfield.tone import ChirpSpec, SyncToneConfig +from syncfield.types import ( + FinalizationReport, + HealthEvent, + HealthEventKind, + SampleEvent, + SessionReport, + SessionState, + StreamCapabilities, + StreamKind, + SyncPoint, +) + +__all__ = [ + "SessionOrchestrator", + "Stream", + "StreamBase", + "StreamCapabilities", + "StreamKind", + "SessionClock", + "SessionState", + "SessionReport", + "FinalizationReport", + "HealthEvent", + "HealthEventKind", + "SampleEvent", + "SyncPoint", + "SyncToneConfig", + "ChirpSpec", +] __version__ = _pkg_version("syncfield") diff --git a/src/syncfield/adapters/__init__.py b/src/syncfield/adapters/__init__.py new file mode 100644 index 0000000..c46b353 --- /dev/null +++ b/src/syncfield/adapters/__init__.py @@ -0,0 +1,41 @@ +"""Reference :class:`~syncfield.stream.Stream` adapters shipped with syncfield. + +Adapters with no external dependencies are always re-exported here. +Adapters gated behind optional extras are re-exported **lazily** — if the +corresponding extra is not installed, importing ``syncfield.adapters`` still +succeeds but that specific class is simply absent from the module. + +========================= ==================================== ===================== +Adapter Requires Install +========================= ==================================== ===================== +``JSONLFileStream`` — ``syncfield`` +``UVCWebcamStream`` ``opencv-python`` ``syncfield[uvc]`` +``BLEImuGenericStream`` ``bleak`` ``syncfield[ble]`` +========================= ==================================== ===================== + +Users who need a specific optional adapter can always import it directly +(e.g. ``from syncfield.adapters.uvc_webcam import UVCWebcamStream``) — that +path raises a clear :class:`ImportError` with an install hint when the +dependency is missing. +""" + +from syncfield.adapters.jsonl_file import JSONLFileStream + +__all__ = ["JSONLFileStream"] + + +# --------------------------------------------------------------------------- +# Optional re-exports — never fatal if the corresponding extra is missing. +# --------------------------------------------------------------------------- + +try: + from syncfield.adapters.uvc_webcam import UVCWebcamStream # noqa: F401 + __all__.append("UVCWebcamStream") +except ImportError: + pass + +try: + from syncfield.adapters.ble_imu import BLEImuGenericStream # noqa: F401 + __all__.append("BLEImuGenericStream") +except ImportError: + pass diff --git a/src/syncfield/adapters/ble_imu.py b/src/syncfield/adapters/ble_imu.py new file mode 100644 index 0000000..03c4e2b --- /dev/null +++ b/src/syncfield/adapters/ble_imu.py @@ -0,0 +1,211 @@ +"""BLEImuGenericStream — generic BLE IMU reference adapter using ``bleak``. + +Connects to a BLE peripheral by MAC address (or platform-specific UUID on +macOS) and subscribes to a single notify characteristic. Each notification +payload is parsed with a user-provided :mod:`struct` format string and +emitted as a :class:`~syncfield.types.SampleEvent` with per-channel values. + +Because ``bleak`` is an asyncio library and the orchestrator API is +synchronous, this adapter spins up an :class:`asyncio.AbstractEventLoop` +on an internal background thread. The main thread and the loop thread +communicate via a :class:`threading.Event` to signal stop. + +Requires the optional ``ble`` extra: + + pip install syncfield[ble] +""" + +from __future__ import annotations + +import asyncio +import struct +import threading +import time +from typing import Any, Optional, Tuple + +try: + import bleak # type: ignore[import-not-found] +except ImportError as exc: # pragma: no cover - exercised via sys.modules patch + raise ImportError( + "BLEImuGenericStream requires bleak. " + "Install with `pip install syncfield[ble]`." + ) from exc + +from syncfield.clock import SessionClock +from syncfield.stream import StreamBase +from syncfield.types import ( + FinalizationReport, + HealthEvent, + HealthEventKind, + SampleEvent, + StreamCapabilities, +) + + +class BLEImuGenericStream(StreamBase): + """Generic BLE IMU adapter. + + Args: + id: Stream id. + mac: Peripheral MAC address (or platform-specific UUID on macOS). + characteristic_uuid: UUID of the notify characteristic. + frame_format: ``struct`` format for decoding notification payloads. + Default ``" None: + super().__init__( + id=id, + kind="sensor", + capabilities=StreamCapabilities( + provides_audio_track=False, + supports_precise_timestamps=True, + is_removable=True, + produces_file=False, + ), + ) + self._mac = mac + self._uuid = characteristic_uuid + self._format = frame_format + self._channel_names = channel_names + + # Compute how many values the format produces by unpacking a + # zero-filled buffer of the correct size. + produced = struct.unpack(frame_format, b"\x00" * struct.calcsize(frame_format)) + if len(channel_names) != len(produced): + raise ValueError( + f"channel_names has {len(channel_names)} entries but format " + f"{frame_format!r} produces {len(produced)} values" + ) + + self._client: Any = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._frame_count = 0 + self._first_at: Optional[int] = None + self._last_at: Optional[int] = None + + # ------------------------------------------------------------------ + # Stream SPI + # ------------------------------------------------------------------ + + def prepare(self) -> None: + self._client = bleak.BleakClient(self._mac) + + def start(self, session_clock: SessionClock) -> None: + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_event_loop, + name=f"ble-{self.id}", + daemon=True, + ) + self._thread.start() + + def stop(self) -> FinalizationReport: + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=3.0) + + return FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=self._frame_count, + file_path=None, + first_sample_at_ns=self._first_at, + last_sample_at_ns=self._last_at, + health_events=list(self._collected_health), + error=None, + ) + + # ------------------------------------------------------------------ + # Async runtime on the background thread + # ------------------------------------------------------------------ + + def _run_event_loop(self) -> None: + """Body of the background thread — owns a private asyncio loop.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._session()) + finally: + self._loop.close() + + async def _session(self) -> None: + """Connect, subscribe, poll the stop flag, then disconnect.""" + try: + await self._client.connect() + await self._client.start_notify(self._uuid, self._on_notify) + while not self._stop_event.is_set(): + await asyncio.sleep(0.05) + await self._client.stop_notify(self._uuid) + await self._client.disconnect() + except Exception as exc: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.ERROR, + at_ns=time.monotonic_ns(), + detail=str(exc), + ) + ) + + async def _on_notify(self, characteristic: Any, payload: bytes) -> None: + """Bleak notify handler — forwards to the sync decode path.""" + self._handle_payload(payload) + + # ------------------------------------------------------------------ + # Payload decoding (unit-testable without asyncio) + # ------------------------------------------------------------------ + + def _handle_payload(self, payload: bytes) -> None: + """Decode a raw BLE payload into a :class:`SampleEvent` and emit. + + Decode failures become WARNING health events rather than raising + so a single malformed notification cannot tear down the stream. + """ + capture_ns = time.monotonic_ns() + try: + values = struct.unpack(self._format, payload) + except struct.error as exc: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.WARNING, + at_ns=capture_ns, + detail=f"payload decode failed: {exc}", + ) + ) + return + + if self._first_at is None: + self._first_at = capture_ns + self._last_at = capture_ns + self._frame_count += 1 + + self._emit_sample( + SampleEvent( + stream_id=self.id, + frame_number=self._frame_count - 1, + capture_ns=capture_ns, + channels=dict(zip(self._channel_names, values)), + ) + ) + + def _dispatch_notification_for_test(self, payload: bytes) -> None: + """Test-only hook: push a payload through the decode path synchronously.""" + self._handle_payload(payload) diff --git a/src/syncfield/adapters/jsonl_file.py b/src/syncfield/adapters/jsonl_file.py new file mode 100644 index 0000000..448ce49 --- /dev/null +++ b/src/syncfield/adapters/jsonl_file.py @@ -0,0 +1,81 @@ +"""JSONLFileStream — adapt a customer-owned JSONL file into a Stream. + +Use this adapter when you already have a process writing per-sample JSONL +records and you just want the orchestrator to track lifecycle and include +the file in the manifest. The adapter performs **no I/O of its own** during +recording — it only inspects the file on ``stop()`` to report a frame count. + +This is the "bring your own writer" degenerate adapter; it ships with no +optional dependencies and is always importable. +""" + +from __future__ import annotations + +from pathlib import Path + +from syncfield.clock import SessionClock +from syncfield.stream import StreamBase +from syncfield.types import FinalizationReport, StreamCapabilities + + +class JSONLFileStream(StreamBase): + """Wraps an external JSONL file as a Stream. + + The caller is responsible for writing the file on their own schedule — + this adapter only tracks lifecycle and reports the file path in the + :class:`FinalizationReport` (and thus the manifest). On ``stop()`` it + counts the number of lines in the file; if the file does not exist, + the status is ``"partial"``. + + Args: + id: Stream id. + file_path: Path to the JSONL file that the customer will write. + """ + + def __init__(self, id: str, file_path: Path | str) -> None: + super().__init__( + id=id, + kind="sensor", + capabilities=StreamCapabilities( + provides_audio_track=False, + # Precision depends on the customer's writer, not this adapter, + # so we conservatively advertise False. + supports_precise_timestamps=False, + is_removable=False, + produces_file=True, + ), + ) + self._file_path = Path(file_path) + self._prepared = False + self._started = False + + def prepare(self) -> None: + self._prepared = True + + def start(self, session_clock: SessionClock) -> None: + if not self._prepared: + raise RuntimeError("JSONLFileStream.start() called without prepare()") + self._started = True + + def stop(self) -> FinalizationReport: + frame_count = 0 + status: str = "completed" + file_path = self._file_path + + if file_path.exists(): + with file_path.open() as f: + frame_count = sum(1 for _ in f) + else: + status = "partial" + file_path = None # type: ignore[assignment] + + return FinalizationReport( + stream_id=self.id, + status=status, # type: ignore[arg-type] + frame_count=frame_count, + file_path=file_path, + first_sample_at_ns=None, + last_sample_at_ns=None, + health_events=list(self._collected_health), + error=None, + ) diff --git a/src/syncfield/adapters/uvc_webcam.py b/src/syncfield/adapters/uvc_webcam.py new file mode 100644 index 0000000..ccfa08b --- /dev/null +++ b/src/syncfield/adapters/uvc_webcam.py @@ -0,0 +1,173 @@ +"""UVCWebcamStream — OpenCV-based reference adapter for UVC/USB webcams. + +Requires the optional ``uvc`` extra: + + pip install syncfield[uvc] + +The adapter runs a background thread that reads frames in a tight loop, +timestamps each read with ``time.monotonic_ns()`` **before** any further +processing, emits a :class:`~syncfield.types.SampleEvent`, and writes the +frame to an MP4 via ``cv2.VideoWriter``. +""" + +from __future__ import annotations + +import threading +import time +from pathlib import Path +from typing import Any, Optional + +try: + import cv2 # type: ignore[import-not-found] +except ImportError as exc: # pragma: no cover - exercised via sys.modules patch + raise ImportError( + "UVCWebcamStream requires opencv-python. " + "Install with `pip install syncfield[uvc]`." + ) from exc + +from syncfield.clock import SessionClock +from syncfield.stream import StreamBase +from syncfield.types import ( + FinalizationReport, + SampleEvent, + StreamCapabilities, +) + + +class UVCWebcamStream(StreamBase): + """Captures video from a UVC webcam via OpenCV. + + Args: + id: Stream id (also used as the output file name, ``{id}.mp4``). + device_index: OpenCV device index passed to ``cv2.VideoCapture``. + output_dir: Directory for the resulting MP4 file. + width: Desired frame width (or ``None`` to use the device default). + height: Desired frame height (or ``None`` to use the device default). + fps: Desired frame rate (or ``None`` to use the device default). + """ + + def __init__( + self, + id: str, + device_index: int, + output_dir: Path | str, + width: Optional[int] = None, + height: Optional[int] = None, + fps: Optional[float] = None, + ) -> None: + super().__init__( + id=id, + kind="video", + capabilities=StreamCapabilities( + provides_audio_track=False, # OpenCV webcams have no audio path + supports_precise_timestamps=True, + is_removable=True, + produces_file=True, + ), + ) + self._device_index = device_index + self._output_dir = Path(output_dir) + self._width = width + self._height = height + self._fps = fps + + self._capture: Any = None + self._writer: Any = None + self._file_path = self._output_dir / f"{id}.mp4" + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._frame_count = 0 + self._first_at: Optional[int] = None + self._last_at: Optional[int] = None + + # ------------------------------------------------------------------ + # Stream SPI + # ------------------------------------------------------------------ + + def prepare(self) -> None: + self._output_dir.mkdir(parents=True, exist_ok=True) + self._capture = cv2.VideoCapture(self._device_index) + if not self._capture.isOpened(): + raise RuntimeError( + f"cv2.VideoCapture({self._device_index}) failed to open" + ) + + def start(self, session_clock: SessionClock) -> None: + width, height, fps = self._resolve_frame_geometry() + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + self._writer = cv2.VideoWriter( + str(self._file_path), fourcc, fps, (width, height) + ) + self._stop_event.clear() + self._thread = threading.Thread( + target=self._capture_loop, name=f"uvc-{self.id}", daemon=True + ) + self._thread.start() + + def stop(self) -> FinalizationReport: + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._release_cv2_resources() + + return FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=self._frame_count, + file_path=self._file_path if self._frame_count > 0 else None, + first_sample_at_ns=self._first_at, + last_sample_at_ns=self._last_at, + health_events=list(self._collected_health), + error=None, + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_frame_geometry(self) -> tuple[int, int, float]: + """Pick width, height, fps — constructor overrides beat device defaults.""" + width = self._width or int( + self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 640 + ) + height = self._height or int( + self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480 + ) + fps = self._fps or self._capture.get(cv2.CAP_PROP_FPS) or 30.0 + return width, height, fps + + def _capture_loop(self) -> None: + """Background thread body — read/timestamp/emit/write in a tight loop. + + The timestamp is captured immediately after ``read()`` so the + jitter between the physical frame and our recorded timestamp + stays as small as possible. + """ + assert self._capture is not None + while not self._stop_event.is_set(): + ok, frame = self._capture.read() + capture_ns = time.monotonic_ns() + if not ok or frame is None: + break + if self._first_at is None: + self._first_at = capture_ns + self._last_at = capture_ns + self._frame_count += 1 + + if self._writer is not None: + self._writer.write(frame) + self._emit_sample( + SampleEvent( + stream_id=self.id, + frame_number=self._frame_count - 1, + capture_ns=capture_ns, + ) + ) + + def _release_cv2_resources(self) -> None: + if self._writer is not None: + self._writer.release() + self._writer = None + if self._capture is not None: + self._capture.release() + self._capture = None diff --git a/src/syncfield/capture.py b/src/syncfield/capture.py deleted file mode 100644 index ecba8cf..0000000 --- a/src/syncfield/capture.py +++ /dev/null @@ -1,262 +0,0 @@ -"""SyncSession — the main user-facing class for timestamp capture.""" - -from __future__ import annotations - -import time -import threading -from pathlib import Path -from typing import Any - -from syncfield.types import FrameTimestamp, SensorSample, SyncPoint -from syncfield.writer import SensorWriter, StreamWriter, write_manifest, write_sync_point - - -class SyncSession: - """Capture timestamps for multi-stream synchronization. - - Usage:: - - session = SyncSession(host_id="rig_01", output_dir="./timestamps") - session.start() - - # In your I/O loop — call stamp() immediately AFTER each read() - frame = camera.read() - session.stamp("cam_left", frame_number=i) - - session.stop() - - The session is **thread-safe**: ``stamp()`` can be called from multiple - threads concurrently (e.g. one thread per device). - - Output files (written to *output_dir*):: - - sync_point.json - cam_left.timestamps.jsonl - cam_right.timestamps.jsonl - ... - """ - - def __init__(self, host_id: str, output_dir: str | Path) -> None: - self._host_id = host_id - self._output_dir = Path(output_dir) - self._sync_point: SyncPoint | None = None - self._writers: dict[str, StreamWriter] = {} - self._sensor_writers: dict[str, SensorWriter] = {} - self._links: dict[str, str] = {} - self._recorded_streams: set[str] = set() - self._lock = threading.Lock() - self._started = False - - @property - def sync_point(self) -> SyncPoint | None: - return self._sync_point - - def start(self) -> SyncPoint: - """Begin a recording session. - - Captures a :class:`SyncPoint` and prepares the output directory. - Must be called before :meth:`stamp`. - - Returns: - The captured :class:`SyncPoint`. - - Raises: - RuntimeError: If the session is already started. - """ - if self._started: - raise RuntimeError("Session already started") - self._output_dir.mkdir(parents=True, exist_ok=True) - self._sync_point = SyncPoint.create_now(self._host_id) - self._started = True - return self._sync_point - - def stamp( - self, - stream_id: str, - frame_number: int, - uncertainty_ns: int = 5_000_000, - capture_ns: int | None = None, - ) -> int: - """Record a timestamp for one data packet. - - Call this **immediately after** your I/O read completes — before any - processing — to minimise jitter. - - Args: - stream_id: Identifier for the data stream (e.g. ``"cam_left"``). - frame_number: Sequential index (0-based) within this stream. - uncertainty_ns: Timing uncertainty estimate (default 5 ms). - capture_ns: Pre-captured ``time.monotonic_ns()`` value. If - ``None`` (default), the SDK captures it at call time. - - Returns: - The ``time.monotonic_ns()`` value used for this timestamp. - - Raises: - RuntimeError: If :meth:`start` has not been called. - """ - if not self._started: - raise RuntimeError("Session not started — call start() first") - - if capture_ns is None: - capture_ns = time.monotonic_ns() - - ts = FrameTimestamp( - frame_number=frame_number, - capture_ns=capture_ns, - clock_source="host_monotonic", - clock_domain=self._host_id, - uncertainty_ns=uncertainty_ns, - ) - - with self._lock: - writer = self._writers.get(stream_id) - if writer is None: - writer = StreamWriter(stream_id, self._output_dir) - writer.open() - self._writers[stream_id] = writer - writer.write(ts) - - return capture_ns - - def record( - self, - stream_id: str, - frame_number: int, - channels: dict[str, Any], - uncertainty_ns: int = 5_000_000, - capture_ns: int | None = None, - ) -> int: - """Record a sensor sample with timestamp and channel data. - - Captures ``time.monotonic_ns()``, then writes to both - ``{stream_id}.timestamps.jsonl`` and ``{stream_id}.jsonl``. - - Channels can be flat (``{"accel_x": 0.12}``) or nested - (``{"joints": {"wrist": [0.1, 0.2, 0.3]}}``). - - Args: - stream_id: Identifier for the sensor stream (e.g. ``"imu"``). - frame_number: Sequential index (0-based) within this stream. - channels: Sensor data as ``{name: value}`` pairs. Values can be - floats, lists, or nested dicts for complex sensors. - uncertainty_ns: Timing uncertainty estimate (default 5 ms). - capture_ns: Pre-captured ``time.monotonic_ns()`` value. If - ``None`` (default), the SDK captures it at call time. - - Returns: - The ``time.monotonic_ns()`` value used for this timestamp. - - Raises: - RuntimeError: If :meth:`start` has not been called. - """ - if not self._started: - raise RuntimeError("Session not started — call start() first") - - if capture_ns is None: - capture_ns = time.monotonic_ns() - - ts = FrameTimestamp( - frame_number=frame_number, - capture_ns=capture_ns, - clock_source="host_monotonic", - clock_domain=self._host_id, - uncertainty_ns=uncertainty_ns, - ) - - sample = SensorSample( - frame_number=frame_number, - capture_ns=capture_ns, - channels=channels, - clock_source="host_monotonic", - clock_domain=self._host_id, - uncertainty_ns=uncertainty_ns, - ) - - with self._lock: - # Timestamp writer - ts_writer = self._writers.get(stream_id) - if ts_writer is None: - ts_writer = StreamWriter(stream_id, self._output_dir) - ts_writer.open() - self._writers[stream_id] = ts_writer - ts_writer.write(ts) - - # Sensor data writer - sensor_writer = self._sensor_writers.get(stream_id) - if sensor_writer is None: - sensor_writer = SensorWriter(stream_id, self._output_dir) - sensor_writer.open() - self._sensor_writers[stream_id] = sensor_writer - sensor_writer.write(sample) - - self._recorded_streams.add(stream_id) - - return capture_ns - - def link(self, stream_id: str, path: str | Path) -> None: - """Associate an external file path with a stream. - - Use this for files produced outside the SDK (e.g. video files, - pre-converted sensor files). The association is recorded in - ``manifest.json`` when :meth:`stop` is called. - - Args: - stream_id: The stream identifier. - path: Path to the external file. - """ - self._links[stream_id] = str(path) - - def stop(self) -> dict[str, int]: - """End the recording session. - - Closes all writers and writes ``sync_point.json`` and - ``manifest.json``. - - Returns: - Mapping of ``{stream_id: frame_count}`` for all recorded streams. - - Raises: - RuntimeError: If :meth:`start` has not been called. - """ - if not self._started: - raise RuntimeError("Session not started") - - counts: dict[str, int] = {} - for stream_id, writer in self._writers.items(): - counts[stream_id] = writer.count - writer.close() - - for writer in self._sensor_writers.values(): - writer.close() - - if self._sync_point is not None: - write_sync_point(self._sync_point, self._output_dir) - - # Build manifest - streams: dict[str, dict[str, Any]] = {} - all_ids = sorted( - set(self._writers) | set(self._links) | self._recorded_streams, - ) - for stream_id in all_ids: - entry: dict[str, Any] = {} - - if stream_id in self._recorded_streams: - entry["type"] = "sensor" - entry["sensor_path"] = f"{stream_id}.jsonl" - else: - entry["type"] = "video" - - if stream_id in self._writers: - entry["timestamps_path"] = f"{stream_id}.timestamps.jsonl" - entry["frame_count"] = counts[stream_id] - - if stream_id in self._links: - entry["path"] = self._links[stream_id] - - streams[stream_id] = entry - - write_manifest(self._host_id, streams, self._output_dir) - - self._started = False - return counts diff --git a/src/syncfield/clock.py b/src/syncfield/clock.py new file mode 100644 index 0000000..1f2cc37 --- /dev/null +++ b/src/syncfield/clock.py @@ -0,0 +1,49 @@ +"""SessionClock — immutable clock handle distributed to all Streams in a session. + +Captured once by :class:`syncfield.orchestrator.SessionOrchestrator` at +``start()`` and passed to every ``Stream.start()`` call. Provides the session's +:class:`syncfield.types.SyncPoint` together with a small helper API so +individual streams never need to import :mod:`time` or touch the session +anchor directly. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +from syncfield.types import SyncPoint + + +@dataclass(frozen=True) +class SessionClock: + """Shared monotonic clock reference for all streams in one session. + + A ``SessionClock`` is cheap to copy, safe to share across threads, and + binds each stream in a session to the exact same monotonic anchor. The + orchestrator constructs it once at ``start()`` and distributes it to + every ``Stream.start(session_clock)`` call so intra-session timing uses + a single source of truth. + + Attributes: + sync_point: The session's :class:`SyncPoint` (monotonic + wall clock + anchor captured at session start). + """ + + sync_point: SyncPoint + + @property + def host_id(self) -> str: + """Host identifier for this session.""" + return self.sync_point.host_id + + def now_ns(self) -> int: + """Return the current monotonic nanosecond timestamp. + + Thread-safe: :func:`time.monotonic_ns` is atomic on CPython. + """ + return time.monotonic_ns() + + def elapsed_ns(self) -> int: + """Return nanoseconds elapsed since the session's sync point.""" + return time.monotonic_ns() - self.sync_point.monotonic_ns diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py new file mode 100644 index 0000000..2fba249 --- /dev/null +++ b/src/syncfield/orchestrator.py @@ -0,0 +1,428 @@ +"""SessionOrchestrator — lifecycle coordinator for a multi-stream capture session. + +The orchestrator owns state transitions, atomic start/stop across all +registered streams, chirp injection, crash-safe session logging, and +health-event routing. Each instance represents **one host**; multi-host +coordination happens at the sync core when outputs from multiple hosts +are submitted together. + +The file is organized top-down so the public lifecycle is easy to read: + +1. Construction and public properties +2. ``add()`` — stream registration +3. ``start()`` — atomic multi-stream start with rollback +4. ``stop()`` — chirp + finalization + artifact persistence +5. Session log helpers (crash safety) +6. Chirp injection helpers + +Thread safety: + ``add()`` is **not** thread-safe — call it from the thread that + constructed the session. ``start()`` and ``stop()`` acquire an + internal reentrant lock, so it is safe for other threads to observe + state but only one lifecycle transition runs at a time. +""" + +from __future__ import annotations + +import logging +import threading +import time +from pathlib import Path +from typing import Dict, List, Optional + +from syncfield.clock import SessionClock +from syncfield.stream import Stream +from syncfield.tone import ChirpPlayer, SyncToneConfig, create_default_player +from syncfield.types import ( + FinalizationReport, + HealthEvent, + SessionReport, + SessionState, + SyncPoint, +) +from syncfield.writer import SessionLogWriter, write_manifest, write_sync_point + +logger = logging.getLogger(__name__) + + +class SessionOrchestrator: + """Coordinates a multi-stream recording session for one host. + + Args: + host_id: Identifier for this capture host. Must match across all + orchestrators belonging to the same logical host. + output_dir: Directory where all output files are written. Created + if it does not exist. + sync_tone: Chirp configuration. Defaults to enabled with the + egonaut production chirp spec. Use + :meth:`~syncfield.tone.SyncToneConfig.silent` to disable. + """ + + def __init__( + self, + host_id: str, + output_dir: Path | str, + sync_tone: SyncToneConfig | None = None, + chirp_player: ChirpPlayer | None = None, + ) -> None: + self._host_id = host_id + self._output_dir = Path(output_dir) + self._output_dir.mkdir(parents=True, exist_ok=True) + self._sync_tone = sync_tone or SyncToneConfig.default() + self._chirp_player = chirp_player or create_default_player() + self._streams: Dict[str, Stream] = {} + self._state = SessionState.IDLE + self._lock = threading.RLock() + + # Populated during start(); consumed during stop(). + self._sync_point: Optional[SyncPoint] = None + self._session_clock: Optional[SessionClock] = None + self._chirp_start_ns: Optional[int] = None + self._chirp_stop_ns: Optional[int] = None + self._log_writer: Optional[SessionLogWriter] = None + + # ------------------------------------------------------------------ + # Public properties + # ------------------------------------------------------------------ + + @property + def host_id(self) -> str: + return self._host_id + + @property + def state(self) -> SessionState: + return self._state + + @property + def output_dir(self) -> Path: + return self._output_dir + + # ------------------------------------------------------------------ + # Stream registration + # ------------------------------------------------------------------ + + def add(self, stream: Stream) -> None: + """Register a stream with this session. + + Must be called before :meth:`start`. Duplicate stream ids are + rejected so session output files are always unique. Once + ``start()`` has been called, any health events the stream emits + are forwarded to the session log automatically. + + Raises: + ValueError: If a stream with the same id is already registered. + RuntimeError: If the session is not in the ``IDLE`` state. + """ + if self._state is not SessionState.IDLE: + raise RuntimeError( + f"add() requires IDLE state; current state is {self._state.value}" + ) + if stream.id in self._streams: + raise ValueError(f"duplicate stream id: {stream.id!r}") + self._streams[stream.id] = stream + stream.on_health(self._on_stream_health) + + # ------------------------------------------------------------------ + # Lifecycle — start + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start every registered stream atomically. + + Sequence: + 1. Validate state (must be ``IDLE``) and that at least one + stream is registered. + 2. Capture a fresh :class:`~syncfield.types.SyncPoint` and + build the shared :class:`~syncfield.clock.SessionClock`. + 3. For each stream: call ``prepare()`` then + ``start(session_clock)``. If any call raises, roll back all + streams that were fully started (stopping them in reverse + order) and re-raise the original exception. + 4. On success, transition to ``RECORDING``. + + The failed stream itself is **not** rolled back — it never reached + a successfully-started state. + + Raises: + RuntimeError: If state is not ``IDLE`` or no streams are + registered. + Exception: Any exception raised by a stream during + ``prepare``/``start`` propagates after rollback. State + returns to ``IDLE`` before the exception escapes. + """ + with self._lock: + if self._state is not SessionState.IDLE: + raise RuntimeError( + f"start() requires IDLE state; current state is {self._state.value}" + ) + if not self._streams: + raise RuntimeError("cannot start() with no streams registered") + + # Open the crash-safe session log BEFORE any state mutation so + # failures during preparation are still recorded on disk. + self._log_writer = SessionLogWriter(self._output_dir) + self._log_writer.open() + + self._transition(SessionState.PREPARING) + self._sync_point = SyncPoint.create_now(self._host_id) + self._session_clock = SessionClock(sync_point=self._sync_point) + + started: List[Stream] = [] + try: + for stream in self._streams.values(): + stream.prepare() + stream.start(self._session_clock) + started.append(stream) + except Exception as exc: + self._log_rollback(exc, len(started)) + self._rollback_started_streams(started) + self._transition(SessionState.IDLE) + raise + + self._maybe_play_start_chirp() + self._transition(SessionState.RECORDING) + + @staticmethod + def _rollback_started_streams(started: List[Stream]) -> None: + """Best-effort tear-down of streams that were fully started. + + Called when ``start()`` fails partway through. Streams are stopped + in reverse order (LIFO) so later-started streams release their + resources before earlier-started ones. Any exceptions raised by + ``stop()`` during rollback are swallowed — the primary failure is + already on its way up the stack and is the real story. + """ + for s in reversed(started): + try: + s.stop() + except Exception: # pragma: no cover — best-effort cleanup + pass + + # ------------------------------------------------------------------ + # Lifecycle — stop + # ------------------------------------------------------------------ + + def stop(self) -> SessionReport: + """Stop all streams and persist session artifacts. + + Sequence: + 1. Validate state (must be ``RECORDING``) and transition to + ``STOPPING``. + 2. If chirp is eligible, play the stop chirp **before** + stopping streams so it lands in recording audio tracks, + then wait for its tail to flush. + 3. For each stream, call ``stop()``. Exceptions become failed + :class:`FinalizationReport` entries — one slow or broken + stream must never block finalization of the others. + 4. Write ``sync_point.json`` and ``manifest.json`` to the + output directory. + 5. Transition to ``STOPPED``, close the session log, and + return the aggregated :class:`SessionReport`. + + Returns: + Aggregated :class:`SessionReport` with per-stream + finalization reports and chirp timestamps (if a chirp was + played). + + Raises: + RuntimeError: If state is not ``RECORDING``. + """ + with self._lock: + if self._state is not SessionState.RECORDING: + raise RuntimeError( + f"stop() requires RECORDING state; current state is {self._state.value}" + ) + self._transition(SessionState.STOPPING) + + self._maybe_play_stop_chirp_and_wait() + + finalizations = self._finalize_streams() + self._persist_session_artifacts(finalizations) + + self._transition(SessionState.STOPPED) + if self._log_writer is not None: + self._log_writer.close() + self._log_writer = None + return SessionReport( + host_id=self._host_id, + finalizations=finalizations, + chirp_start_ns=self._chirp_start_ns, + chirp_stop_ns=self._chirp_stop_ns, + ) + + def _finalize_streams(self) -> List[FinalizationReport]: + """Call ``stop()`` on each stream and collect FinalizationReports. + + Stream exceptions are converted to failed reports so that one + broken stream cannot prevent the session from reaching a clean + ``STOPPED`` state. All finalize work for one stream happens + before moving on to the next. + """ + finalizations: List[FinalizationReport] = [] + for stream in self._streams.values(): + try: + report = stream.stop() + except Exception as exc: + report = FinalizationReport( + stream_id=stream.id, + status="failed", + frame_count=0, + file_path=None, + first_sample_at_ns=None, + last_sample_at_ns=None, + health_events=[], + error=str(exc), + ) + finalizations.append(report) + return finalizations + + def _persist_session_artifacts( + self, + finalizations: List[FinalizationReport], + ) -> None: + """Write ``sync_point.json`` and ``manifest.json``. + + Assumes ``start()`` has already captured ``self._sync_point``; + safe because ``stop()`` requires ``RECORDING`` state which can + only be entered through ``start()``. Chirp fields are included + only when a chirp was actually played — the writer omits + ``chirp_*`` fields otherwise. + """ + assert self._sync_point is not None # guaranteed by state check + + chirp_spec = ( + self._sync_tone.start_chirp if self._chirp_start_ns is not None else None + ) + write_sync_point( + self._sync_point, + self._output_dir, + chirp_start_ns=self._chirp_start_ns, + chirp_stop_ns=self._chirp_stop_ns, + chirp_spec=chirp_spec, + ) + + streams_dict: Dict[str, dict] = {} + final_by_id = {f.stream_id: f for f in finalizations} + for stream in self._streams.values(): + entry: dict = { + "kind": stream.kind, + "capabilities": stream.capabilities.to_dict(), + } + final = final_by_id.get(stream.id) + if final is not None: + entry["status"] = final.status + entry["frame_count"] = final.frame_count + if final.file_path is not None: + entry["path"] = str(final.file_path) + if final.error is not None: + entry["error"] = final.error + streams_dict[stream.id] = entry + + write_manifest(self._host_id, streams_dict, self._output_dir) + + # ------------------------------------------------------------------ + # Session log helpers (crash safety) + # ------------------------------------------------------------------ + + def _transition(self, new_state: SessionState) -> None: + """Record a state transition in the session log and update state. + + This is the single source of truth for state mutations after the + session log has been opened. Every transition is flushed to disk + immediately so a crash mid-recording still leaves an ordered + timeline that the sync core can reconstruct. + """ + old = self._state + self._state = new_state + if self._log_writer is not None: + self._log_writer.log_event( + { + "kind": "state_transition", + "from": old.value, + "to": new_state.value, + "at_ns": time.monotonic_ns(), + } + ) + + def _log_rollback(self, exc: BaseException, started_count: int) -> None: + """Persist a rollback event with the failing exception for post-mortem.""" + if self._log_writer is None: + return + self._log_writer.log_event( + { + "kind": "rollback", + "reason": str(exc), + "started_count": started_count, + "at_ns": time.monotonic_ns(), + } + ) + + def _on_stream_health(self, event: HealthEvent) -> None: + """Forward a stream-reported health event into the session log. + + Events emitted before :meth:`start` (while the log is not yet + open) are silently buffered by :class:`~syncfield.stream.StreamBase` + and surface later in the :class:`FinalizationReport` so nothing + is lost. + """ + if self._log_writer is not None: + self._log_writer.log_health(event) + + # ------------------------------------------------------------------ + # Chirp injection + # ------------------------------------------------------------------ + + def _is_chirp_eligible(self) -> bool: + """Return True if this host should play sync chirps. + + Chirp eligibility is a host-level check: chirps exist to enable + inter-host audio cross-correlation, so they only matter if at + least one registered stream actually captures audio. Single-host + sessions with no audio stream happily rely on intra-host + timestamp alignment and need no chirp at all. + """ + if not self._sync_tone.enabled: + return False + return any( + s.capabilities.provides_audio_track for s in self._streams.values() + ) + + def _maybe_play_start_chirp(self) -> None: + """Play the start chirp if eligible, else log an INFO line. + + Sleeps ``post_start_stabilization_ms`` first so audio capture + pipelines have time to warm up and begin recording before the + chirp hits the microphone. + """ + if self._is_chirp_eligible(): + time.sleep(self._sync_tone.post_start_stabilization_ms / 1000.0) + self._chirp_start_ns = time.monotonic_ns() + self._chirp_player.play(self._sync_tone.start_chirp) + return + + if self._sync_tone.enabled: + logger.info( + "[%s] No audio-capable stream registered on this host. " + "Chirp injection disabled — host cannot participate in " + "inter-host audio sync. Single-host sessions unaffected.", + self._host_id, + ) + + def _maybe_play_stop_chirp_and_wait(self) -> None: + """Play the stop chirp BEFORE stopping streams and wait for it to flush. + + The stop chirp must be captured in each recording audio track, so + we play it first, then sleep for the chirp's duration plus a + configurable tail margin, then let ``stop()`` proceed to finalize + the streams. + """ + if not self._is_chirp_eligible(): + return + + self._chirp_stop_ns = time.monotonic_ns() + self._chirp_player.play(self._sync_tone.stop_chirp) + total_wait_ms = ( + self._sync_tone.stop_chirp.duration_ms + + self._sync_tone.pre_stop_tail_margin_ms + ) + time.sleep(total_wait_ms / 1000.0) diff --git a/src/syncfield/stream.py b/src/syncfield/stream.py new file mode 100644 index 0000000..bcc7648 --- /dev/null +++ b/src/syncfield/stream.py @@ -0,0 +1,130 @@ +"""Stream SPI — the contract every capture source must satisfy. + +A :class:`Stream` is the fundamental unit that +:class:`~syncfield.orchestrator.SessionOrchestrator` coordinates. Two layers +live here: + +- **Protocol** (:class:`Stream`) — a ``typing.Protocol`` describing the + required attributes and methods. Third-party adapters that already have + their own inheritance tree can conform structurally. +- **Base class** (:class:`StreamBase`) — a convenience superclass that + handles callback registration and the internal health-event buffer so + concrete adapters only need to implement ``prepare``, ``start``, ``stop``. + +All reference adapters in :mod:`syncfield.adapters` inherit from +:class:`StreamBase`. A third-party adapter is free to either inherit or +implement the protocol from scratch; both paths are equally well supported. +""" + +from __future__ import annotations + +from typing import Callable, List, Protocol, runtime_checkable + +from syncfield.clock import SessionClock +from syncfield.types import ( + FinalizationReport, + HealthEvent, + SampleEvent, + StreamCapabilities, + StreamKind, +) + + +SampleCallback = Callable[[SampleEvent], None] +HealthCallback = Callable[[HealthEvent], None] + + +@runtime_checkable +class Stream(Protocol): + """Abstract contract for a capture source managed by SessionOrchestrator. + + Lifecycle: + 1. ``prepare()`` — acquire resources, check permissions. May be called + multiple times and must be idempotent. + 2. ``start(session_clock)`` — begin producing data, anchored to the + session's shared monotonic clock. + 3. ``stop()`` — cease production and return a + :class:`~syncfield.types.FinalizationReport`. + + Callbacks: + - ``on_sample(callback)`` — register a function called on every sample. + - ``on_health(callback)`` — register a function called on health events. + + Thread safety: + Sample and health callbacks may be invoked from a background thread + owned by the stream. Callback functions must therefore be thread-safe. + """ + + id: str + kind: StreamKind + capabilities: StreamCapabilities + + def prepare(self) -> None: ... + def start(self, session_clock: SessionClock) -> None: ... + def stop(self) -> FinalizationReport: ... + def on_sample(self, callback: SampleCallback) -> None: ... + def on_health(self, callback: HealthCallback) -> None: ... + + +class StreamBase: + """Convenience base class that handles callback wiring. + + Concrete adapters inherit from this and implement ``prepare``, ``start``, + ``stop``. Use ``self._emit_sample(ev)`` and ``self._emit_health(ev)`` from + the data-producing code (e.g. a capture thread) to forward events to + registered callbacks and to the internal health buffer. + + Args: + id: Unique stream identifier within a session. + kind: One of ``"video" | "audio" | "sensor" | "custom"``. + capabilities: What the adapter declares it can provide. + """ + + def __init__( + self, + id: str, + kind: StreamKind, + capabilities: StreamCapabilities, + ) -> None: + self.id = id + self.kind = kind + self.capabilities = capabilities + self._sample_callbacks: List[SampleCallback] = [] + self._health_callbacks: List[HealthCallback] = [] + self._collected_health: List[HealthEvent] = [] + + def on_sample(self, callback: SampleCallback) -> None: + """Register a callback invoked for every sample emitted by this stream.""" + self._sample_callbacks.append(callback) + + def on_health(self, callback: HealthCallback) -> None: + """Register a callback invoked for every health event emitted by this stream.""" + self._health_callbacks.append(callback) + + def _emit_sample(self, event: SampleEvent) -> None: + """Forward a sample event to all registered callbacks. + + Call from the stream's data-producing code (e.g. the frame loop). + Callbacks run inline, so they must be cheap and non-blocking. + """ + for cb in self._sample_callbacks: + cb(event) + + def _emit_health(self, event: HealthEvent) -> None: + """Forward a health event to callbacks and buffer it for finalization.""" + self._collected_health.append(event) + for cb in self._health_callbacks: + cb(event) + + # ------------------------------------------------------------------ + # Lifecycle methods — subclasses must override. + # ------------------------------------------------------------------ + + def prepare(self) -> None: # pragma: no cover - abstract + raise NotImplementedError + + def start(self, session_clock: SessionClock) -> None: # pragma: no cover + raise NotImplementedError + + def stop(self) -> FinalizationReport: # pragma: no cover + raise NotImplementedError diff --git a/src/syncfield/testing.py b/src/syncfield/testing.py new file mode 100644 index 0000000..c4650cd --- /dev/null +++ b/src/syncfield/testing.py @@ -0,0 +1,113 @@ +"""Testing utilities — programmable Stream implementations for unit tests. + +This module is part of the **public** SyncField API surface so that +third-party adapter authors can reuse these helpers when testing their own +orchestrator integrations. Nothing here is marked private. +""" + +from __future__ import annotations + +from typing import Optional + +from syncfield.clock import SessionClock +from syncfield.stream import StreamBase +from syncfield.types import ( + FinalizationReport, + HealthEvent, + HealthEventKind, + SampleEvent, + StreamCapabilities, +) + + +class FakeStream(StreamBase): + """Programmable in-memory :class:`~syncfield.stream.Stream` used by tests. + + Tracks lifecycle call counts and lets the test driver push samples and + health events through the standard callback path. Supports failure + injection via the three ``fail_on_*`` flags so tests can exercise + orchestrator rollback, best-effort stop, and error reporting. + + Args: + id: Stream id. + provides_audio_track: Whether this stream reports audio capability + (used to exercise chirp eligibility in orchestrator tests). + fail_on_prepare: If ``True``, ``prepare()`` raises ``RuntimeError``. + fail_on_start: If ``True``, ``start()`` raises ``RuntimeError``. + fail_on_stop: If ``True``, ``stop()`` returns a failed + :class:`FinalizationReport` instead of raising. + """ + + def __init__( + self, + id: str, + provides_audio_track: bool = False, + fail_on_prepare: bool = False, + fail_on_start: bool = False, + fail_on_stop: bool = False, + ) -> None: + super().__init__( + id=id, + kind="custom", + capabilities=StreamCapabilities( + provides_audio_track=provides_audio_track, + supports_precise_timestamps=True, + is_removable=False, + produces_file=False, + ), + ) + self.prepare_calls = 0 + self.start_calls = 0 + self.stop_calls = 0 + self._fail_on_prepare = fail_on_prepare + self._fail_on_start = fail_on_start + self._fail_on_stop = fail_on_stop + self._frame_count = 0 + self._first_at: Optional[int] = None + self._last_at: Optional[int] = None + + # --- Stream SPI -------------------------------------------------------- + + def prepare(self) -> None: + self.prepare_calls += 1 + if self._fail_on_prepare: + raise RuntimeError("fake failure in prepare") + + def start(self, session_clock: SessionClock) -> None: + self.start_calls += 1 + if self._fail_on_start: + raise RuntimeError("fake failure in start") + + def stop(self) -> FinalizationReport: + self.stop_calls += 1 + status: str = "failed" if self._fail_on_stop else "completed" + error: Optional[str] = "fake failure in stop" if self._fail_on_stop else None + return FinalizationReport( + stream_id=self.id, + status=status, # type: ignore[arg-type] + frame_count=self._frame_count, + file_path=None, + first_sample_at_ns=self._first_at, + last_sample_at_ns=self._last_at, + health_events=list(self._collected_health), + error=error, + ) + + # --- Test-only driving API (not part of the Stream SPI) --------------- + + def push_sample(self, frame_number: int, capture_ns: int) -> None: + """Emit a synthetic sample through the orchestrator's callback path.""" + if self._first_at is None: + self._first_at = capture_ns + self._last_at = capture_ns + self._frame_count += 1 + self._emit_sample(SampleEvent(self.id, frame_number, capture_ns)) + + def push_health( + self, + kind: HealthEventKind, + at_ns: int, + detail: Optional[str] = None, + ) -> None: + """Emit a synthetic health event through the callback path.""" + self._emit_health(HealthEvent(self.id, kind, at_ns, detail)) diff --git a/src/syncfield/tone.py b/src/syncfield/tone.py new file mode 100644 index 0000000..b426792 --- /dev/null +++ b/src/syncfield/tone.py @@ -0,0 +1,301 @@ +"""Sync tone generation, serialization, and playback. + +Generates the linear FM chirp audio signal used by SyncField's +cross-correlation-based multi-host alignment. Chirp defaults (400↔2500 Hz +rising/falling, 500 ms, cosine envelope) are ported directly from the +egonaut production implementation (``EgonautMobile/SoundFeedbackModule.swift``) +which has been validated for reliable xcorr peaks across iPhone microphones +in real field recording conditions. + +The synthesis path is pure standard library (``math`` only) so the core SDK +stays lightweight — no numpy dependency. Playback is optional and uses the +``sounddevice`` package when available, with a graceful silent fallback on +headless machines. +""" + +from __future__ import annotations + +import logging +import math +import struct +import wave +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, List, Protocol, runtime_checkable + +from syncfield.types import ChirpSpec + +logger = logging.getLogger(__name__) + + +# numpy is an optional runtime dependency of SoundDeviceChirpPlayer (sounddevice +# itself needs it internally). Import it lazily here — but at module load time +# rather than inside ``play()`` — so that test fixtures which patch +# ``sys.modules["sounddevice"]`` don't accidentally trigger numpy's C-extension +# one-time initialization failure inside a patch.dict block. +try: + import numpy as _np # type: ignore[import-not-found] +except ImportError: # pragma: no cover - exercised on machines without numpy + _np = None # type: ignore[assignment] + + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +# +# These spec values are ported directly from the egonaut production +# implementation (``EgonautMobile/SoundFeedbackModule.swift``) and have been +# validated against real iPhone microphones in field recording sessions. +# The rising-then-falling asymmetry is intentional: it lets the alignment +# core distinguish start chirps from stop chirps via cross-correlation. + +_DEFAULT_START_CHIRP = ChirpSpec( + from_hz=400, to_hz=2500, duration_ms=500, amplitude=0.8, envelope_ms=15 +) +_DEFAULT_STOP_CHIRP = ChirpSpec( + from_hz=2500, to_hz=400, duration_ms=500, amplitude=0.8, envelope_ms=15 +) + + +def generate_chirp_samples(spec: ChirpSpec, sample_rate: int = 44100) -> List[float]: + """Generate mono PCM float samples for a linear FM chirp with cosine envelope. + + The instantaneous frequency sweeps linearly from ``spec.from_hz`` to + ``spec.to_hz`` over ``spec.duration_ms``. A cosine (raised-cosine) + envelope of length ``spec.envelope_ms`` is applied at attack and release. + Amplitude is scaled by ``spec.amplitude`` (``0.0``–``1.0``). + + Mathematical form:: + + f(t) = f0 + (f1 - f0) * (t / T) + phase(t) = 2π · (f0·t + 0.5·k·t²), k = (f1 - f0) / T + envelope = cosine fade of width ``envelope_ms`` at each end + + Args: + spec: Chirp parameters. + sample_rate: Output sample rate in Hz. Default ``44100``. + + Returns: + Mono list of floats in ``[-amplitude, amplitude]``. + """ + duration_s = spec.duration_ms / 1000.0 + total_samples = int(sample_rate * duration_s) + if total_samples == 0 or spec.amplitude == 0.0: + return [0.0] * total_samples + + f0 = float(spec.from_hz) + f1 = float(spec.to_hz) + sweep_rate = (f1 - f0) / duration_s # Hz/s + + envelope_len = int(sample_rate * spec.envelope_ms / 1000.0) + envelope_len = min(envelope_len, total_samples // 2) + + out: List[float] = [0.0] * total_samples + for i in range(total_samples): + t = i / sample_rate + phase = 2.0 * math.pi * (f0 * t + 0.5 * sweep_rate * t * t) + value = math.sin(phase) + + if envelope_len > 0: + if i < envelope_len: + env = 0.5 * (1.0 - math.cos(math.pi * i / envelope_len)) + elif i >= total_samples - envelope_len: + tail = total_samples - 1 - i + env = 0.5 * (1.0 - math.cos(math.pi * tail / envelope_len)) + else: + env = 1.0 + value *= env + + out[i] = spec.amplitude * value + + return out + + +def _float_to_int16(sample: float) -> int: + """Clamp a float to ``[-1, 1]`` and scale to int16 range.""" + clamped = max(-1.0, min(1.0, sample)) + return int(round(clamped * 32767)) + + +def write_chirp_wav( + spec: ChirpSpec, + path: Path | str, + sample_rate: int = 44100, +) -> Path: + """Write a chirp to a 16-bit mono PCM ``.wav`` file. + + Used by playback backends and for debugging chirp signals. Samples are + clamped to ``[-1, 1]`` before int16 conversion so amplitude overflows + never corrupt the output. + + Args: + spec: Chirp parameters. + path: Output file path (``str`` or :class:`~pathlib.Path`). + sample_rate: Sample rate in Hz. Default ``44100``. + + Returns: + The path that was written, as a :class:`~pathlib.Path`. + """ + out_path = Path(path) + samples = generate_chirp_samples(spec, sample_rate) + int16_samples = [_float_to_int16(s) for s in samples] + frames = struct.pack(f"<{len(int16_samples)}h", *int16_samples) + with wave.open(str(out_path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) # 16-bit + w.setframerate(sample_rate) + w.writeframes(frames) + return out_path + + +@dataclass(frozen=True) +class SyncToneConfig: + """Configuration for automatic audio sync chirp injection. + + Controls whether the :class:`~syncfield.orchestrator.SessionOrchestrator` + plays sync chirps at session start and stop, the parameters of those + chirps, and the timing margins around them so that each chirp is + captured in the recording's audio track. + + Defaults are taken from the egonaut production implementation: + + - ``start_chirp``: 400 → 2500 Hz rising sweep + - ``stop_chirp``: 2500 → 400 Hz falling sweep + - ``duration_ms``: 500 ms each + - ``amplitude``: 0.8 with a 15 ms cosine envelope + - ``post_start_stabilization_ms``: 200 ms (let audio pipelines warm up) + - ``pre_stop_tail_margin_ms``: 200 ms (let the chirp tail flush into WAV) + + Attributes: + enabled: If ``False``, the orchestrator never plays a chirp and + never writes chirp fields to ``sync_point.json``. + start_chirp: Parameters for the chirp played right after all + streams have started. + stop_chirp: Parameters for the chirp played right before the + orchestrator stops all streams. + post_start_stabilization_ms: How long to wait after starting every + stream before playing the start chirp. + pre_stop_tail_margin_ms: Extra wait time (on top of the stop + chirp's own duration) before stopping streams so the chirp + tail is fully captured in any recording audio track. + """ + + enabled: bool = True + start_chirp: ChirpSpec = field(default_factory=lambda: _DEFAULT_START_CHIRP) + stop_chirp: ChirpSpec = field(default_factory=lambda: _DEFAULT_STOP_CHIRP) + post_start_stabilization_ms: int = 200 + pre_stop_tail_margin_ms: int = 200 + + @classmethod + def default(cls) -> "SyncToneConfig": + """Construct with all defaults (chirp enabled).""" + return cls() + + @classmethod + def silent(cls) -> "SyncToneConfig": + """Construct with chirp disabled. + + Use for recording environments where audible chirps are + unacceptable (quiet rooms, meetings) or for headless lab machines + with no audio output path. + """ + return cls(enabled=False) + + +# --------------------------------------------------------------------------- +# Playback +# --------------------------------------------------------------------------- + + +@runtime_checkable +class ChirpPlayer(Protocol): + """Protocol for playing a chirp to the system audio output. + + Implementations must be **non-blocking**: ``play()`` returns immediately + after scheduling the sound. The caller + (:class:`~syncfield.orchestrator.SessionOrchestrator`) is responsible for + all timing margins — it sleeps for the chirp's duration plus the + configured tail margin before stopping streams. + """ + + def play(self, spec: ChirpSpec) -> None: + """Schedule playback of a chirp. Returns immediately.""" + ... + + def is_silent(self) -> bool: + """True if this player produces no actual audio output.""" + ... + + +class SilentChirpPlayer: + """No-op player used when ``sounddevice`` is unavailable or disabled. + + Emits an INFO log line on every ``play()`` so callers can see that a + chirp was requested but not produced. Used automatically on headless + lab machines where :func:`create_default_player` cannot import + ``sounddevice``. + """ + + def play(self, spec: ChirpSpec) -> None: + logger.info( + "SilentChirpPlayer.play(%s): chirp skipped (no audio output)", spec + ) + + def is_silent(self) -> bool: + return True + + +class SoundDeviceChirpPlayer: + """Plays chirps via the optional ``sounddevice`` library, non-blocking. + + ``sounddevice`` is an optional dependency (``pip install syncfield[audio]``). + Prefer :func:`create_default_player` over direct instantiation — it + chooses this backend when ``sounddevice`` imports successfully and falls + back to :class:`SilentChirpPlayer` otherwise. + + Args: + sample_rate: Sample rate used both for sample synthesis and for + the sounddevice stream. Default ``44100``. + """ + + def __init__(self, sample_rate: int = 44100) -> None: + self._sample_rate = sample_rate + + def play(self, spec: ChirpSpec) -> None: + # Lazy sounddevice import keeps this module importable on machines + # that lack the audio extras. sounddevice.play() internally requires + # numpy even if the caller passes a list, so we hand it a float32 + # ndarray constructed from the module-level numpy reference. + import sounddevice as sd # type: ignore[import-not-found] + + samples = generate_chirp_samples(spec, sample_rate=self._sample_rate) + buffer: Any + if _np is not None: + buffer = _np.asarray(samples, dtype=_np.float32) + else: + # No numpy available — pass the raw list and let sounddevice + # raise its own ImportError at play time. Tests mock sd so this + # branch is only hit in real headful runs without numpy. + buffer = samples + sd.play(buffer, self._sample_rate) # non-blocking: returns immediately + + def is_silent(self) -> bool: + return False + + +def create_default_player(sample_rate: int = 44100) -> ChirpPlayer: + """Return the best available :class:`ChirpPlayer` for this environment. + + Returns a :class:`SoundDeviceChirpPlayer` when ``sounddevice`` is + importable, else a :class:`SilentChirpPlayer`. Import errors are + logged at INFO — never raised — so the SDK stays usable on headless + machines with no audio output. + """ + try: + import sounddevice # noqa: F401 + except (ImportError, OSError) as exc: + logger.info( + "sounddevice unavailable (%s); chirp playback disabled", exc + ) + return SilentChirpPlayer() + return SoundDeviceChirpPlayer(sample_rate=sample_rate) diff --git a/src/syncfield/types.py b/src/syncfield/types.py index 1da39a4..1018c11 100644 --- a/src/syncfield/types.py +++ b/src/syncfield/types.py @@ -10,7 +10,9 @@ import time from dataclasses import dataclass from datetime import datetime -from typing import Any, Union +from enum import Enum +from pathlib import Path +from typing import Any, Literal, Union # Sensor channel value type. # Leaf values are always numeric (float | int). @@ -153,3 +155,164 @@ def from_dict(cls, data: dict[str, Any]) -> SensorSample: clock_domain=data.get("clock_domain", "local_host"), uncertainty_ns=data.get("uncertainty_ns", 5_000_000), ) + + +StreamKind = Literal["video", "audio", "sensor", "custom"] + + +@dataclass(frozen=True) +class StreamCapabilities: + """What a Stream declares it can provide. + + Attributes: + provides_audio_track: True if the stream records an audio track + (used to determine chirp eligibility for inter-host sync). + supports_precise_timestamps: True if per-sample timestamps are + accurate to nanosecond resolution. + is_removable: True if the underlying device may disconnect + (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. + """ + + provides_audio_track: bool = False + supports_precise_timestamps: bool = False + is_removable: bool = False + produces_file: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "provides_audio_track": self.provides_audio_track, + "supports_precise_timestamps": self.supports_precise_timestamps, + "is_removable": self.is_removable, + "produces_file": self.produces_file, + } + + +class SessionState(Enum): + """Lifecycle state of a SessionOrchestrator.""" + + IDLE = "idle" + PREPARING = "preparing" + RECORDING = "recording" + STOPPING = "stopping" + STOPPED = "stopped" + + +class HealthEventKind(Enum): + """Category of a health event reported by a Stream.""" + + HEARTBEAT = "heartbeat" + DROP = "drop" + RECONNECT = "reconnect" + WARNING = "warning" + ERROR = "error" + + +@dataclass(frozen=True) +class HealthEvent: + """A stream reports a health observation to the orchestrator. + + Attributes: + stream_id: Stream that emitted the event. + kind: Category of the event. + at_ns: ``time.monotonic_ns()`` when the event was observed. + detail: Optional free-form description. + """ + + stream_id: str + kind: HealthEventKind + at_ns: int + detail: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "stream_id": self.stream_id, + "kind": self.kind.value, + "at_ns": self.at_ns, + "detail": self.detail, + } + + +@dataclass(frozen=True) +class SampleEvent: + """A stream reports a sample (timestamp + optional channels) to the orchestrator.""" + + stream_id: str + frame_number: int + capture_ns: int + channels: dict[str, "ChannelValue"] | None = None + uncertainty_ns: int = 5_000_000 + + +@dataclass +class FinalizationReport: + """Result of stopping a single Stream. + + Attributes: + stream_id: Stream that was finalized. + status: One of ``"completed"``, ``"partial"``, ``"failed"``. + 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. + last_sample_at_ns: Monotonic ns of last sample, or None if empty. + health_events: Health events observed during recording. + error: Error message if status is ``"failed"``. + """ + + stream_id: str + status: Literal["completed", "partial", "failed"] + frame_count: int + file_path: Path | None + first_sample_at_ns: int | None + last_sample_at_ns: int | None + health_events: list[HealthEvent] + error: str | None + + +@dataclass(frozen=True) +class ChirpSpec: + """Specification for an audio sync chirp. + + Linear FM sweep from ``from_hz`` to ``to_hz`` over ``duration_ms``, + with a cosine envelope of ``envelope_ms`` attack/release. + + Attributes: + from_hz: Sweep start frequency (Hz). + to_hz: Sweep end frequency (Hz). + duration_ms: Total duration in milliseconds. + amplitude: Peak amplitude in [0.0, 1.0]. + envelope_ms: Cosine fade in/out duration in milliseconds. + """ + + from_hz: float + to_hz: float + duration_ms: int + amplitude: float + envelope_ms: int + + def to_dict(self) -> dict[str, Any]: + return { + "from_hz": self.from_hz, + "to_hz": self.to_hz, + "duration_ms": self.duration_ms, + "amplitude": self.amplitude, + "envelope_ms": self.envelope_ms, + } + + +@dataclass +class SessionReport: + """Aggregated result of a completed session. + + Attributes: + host_id: Host identifier. + finalizations: Per-stream finalization reports. + chirp_start_ns: Monotonic ns when start chirp was played (or None). + chirp_stop_ns: Monotonic ns when stop chirp was played (or None). + """ + + host_id: str + finalizations: list[FinalizationReport] + chirp_start_ns: int | None + chirp_stop_ns: int | None diff --git a/src/syncfield/writer.py b/src/syncfield/writer.py index 0939d90..30882b2 100644 --- a/src/syncfield/writer.py +++ b/src/syncfield/writer.py @@ -1,14 +1,36 @@ -"""Per-stream JSONL writers for timestamp output.""" +"""Per-stream JSONL writers and session-level artifact writers. + +Three classes of writer live here: + +- :class:`StreamWriter` — per-stream ``{stream_id}.timestamps.jsonl`` for + video-style streams that only emit timestamps. +- :class:`SensorWriter` — per-stream ``{stream_id}.jsonl`` for sensor streams + that embed channel values with each sample. +- :class:`SessionLogWriter` — one-file orchestrator log capturing state + transitions, health events, and rollbacks. Flushes on every write so the + log survives a process crash mid-recording. + +Two helpers produce the session-level JSON artifacts: + +- :func:`write_sync_point` — ``sync_point.json`` (with optional chirp fields). +- :func:`write_manifest` — ``manifest.json`` (arbitrary per-stream metadata, + including capability round-trip). +""" from __future__ import annotations import json -from pathlib import Path -from typing import IO, Any - from importlib.metadata import version as _pkg_version +from pathlib import Path +from typing import IO, Any, Optional -from syncfield.types import FrameTimestamp, SensorSample, SyncPoint +from syncfield.types import ( + ChirpSpec, + FrameTimestamp, + HealthEvent, + SensorSample, + SyncPoint, +) class StreamWriter: @@ -28,6 +50,10 @@ def __init__(self, stream_id: str, output_dir: Path) -> None: def count(self) -> int: return self._count + @property + def path(self) -> Path: + return self._path + def open(self) -> None: self._handle = open(self._path, "w") @@ -83,11 +109,90 @@ def close(self) -> None: self._handle = None -def write_sync_point(sync_point: SyncPoint, output_dir: Path) -> Path: - """Write ``sync_point.json`` to *output_dir* and return the path.""" +class SessionLogWriter: + """Writes orchestrator-level events (state transitions, health, rollbacks). + + One JSON object per line. Flushes on every write so logs survive a + crash mid-recording and the core service can reconstruct partial + sessions from the file. + + Output file: ``session_log.jsonl`` + """ + + def __init__(self, output_dir: Path) -> None: + self._path = output_dir / "session_log.jsonl" + self._handle: IO[str] | None = None + + @property + def path(self) -> Path: + return self._path + + def open(self) -> None: + """Open the log file for writing. Idempotent on an already-open writer.""" + if self._handle is None: + self._handle = open(self._path, "w") + + def log_event(self, event: dict[str, Any]) -> None: + """Serialize *event* as a single JSON line and flush. + + Raises: + RuntimeError: If the writer has not been opened. + """ + if self._handle is None: + raise RuntimeError("SessionLogWriter is not open") + self._handle.write(json.dumps(event, separators=(",", ":")) + "\n") + self._handle.flush() + + def log_health(self, event: HealthEvent) -> None: + """Convenience wrapper that flattens a :class:`HealthEvent` to a log entry.""" + self.log_event( + { + "kind": "health", + "stream_id": event.stream_id, + "health_kind": event.kind.value, + "at_ns": event.at_ns, + "detail": event.detail, + } + ) + + def close(self) -> None: + if self._handle is not None: + self._handle.close() + self._handle = None + + +def write_sync_point( + sync_point: SyncPoint, + output_dir: Path, + chirp_start_ns: Optional[int] = None, + chirp_stop_ns: Optional[int] = None, + chirp_spec: Optional[ChirpSpec] = None, +) -> Path: + """Write ``sync_point.json`` to *output_dir* and return the path. + + Chirp-related fields are **omitted entirely** when ``None`` so single-host + sessions and sessions configured with ``SyncToneConfig.silent()`` produce + clean output that the sync core can ingest without special-casing. + + Args: + sync_point: Captured session sync point. + output_dir: Directory in which to write ``sync_point.json``. + chirp_start_ns: Monotonic ns of the start chirp (if played), else None. + chirp_stop_ns: Monotonic ns of the stop chirp (if played), else None. + chirp_spec: Parameters of the chirp that was played, for reproducibility. + + Returns: + Absolute path to the written file. + """ path = output_dir / "sync_point.json" data: dict[str, Any] = {"sdk_version": _pkg_version("syncfield")} data.update(sync_point.to_dict()) + if chirp_start_ns is not None: + data["chirp_start_ns"] = chirp_start_ns + if chirp_stop_ns is not None: + data["chirp_stop_ns"] = chirp_stop_ns + if chirp_spec is not None: + data["chirp_spec"] = chirp_spec.to_dict() with open(path, "w") as f: json.dump(data, f, indent=2) f.write("\n") @@ -99,7 +204,13 @@ def write_manifest( streams: dict[str, dict[str, Any]], output_dir: Path, ) -> Path: - """Write ``manifest.json`` to *output_dir* and return the path.""" + """Write ``manifest.json`` to *output_dir* and return the path. + + The ``streams`` argument is written verbatim under the ``"streams"`` key, + so callers may include any additional per-stream metadata — including + ``"capabilities"`` dictionaries produced by + :meth:`syncfield.types.StreamCapabilities.to_dict`. + """ path = output_dir / "manifest.json" manifest: dict[str, Any] = { "sdk_version": _pkg_version("syncfield"), diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_round_trip.py b/tests/integration/test_round_trip.py new file mode 100644 index 0000000..55a8135 --- /dev/null +++ b/tests/integration/test_round_trip.py @@ -0,0 +1,116 @@ +"""Round-trip integration test: full session → on-disk files → schema check. + +Validates that a SessionOrchestrator-driven session produces output files +whose shape matches what the existing SyncField sync core ingests +(``manifest.json``, ``sync_point.json``, and per-stream JSONL). This test +is deliberately high-level — no mocks of our own types — so that it +exercises the whole stack end-to-end. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from syncfield import SessionOrchestrator, SyncToneConfig +from syncfield.testing import FakeStream + + +def _two_stream_session(tmp_path: Path) -> SessionOrchestrator: + session = SessionOrchestrator( + host_id="rig_01", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + ) + session.add(FakeStream("cam_a", provides_audio_track=True)) + session.add(FakeStream("imu_a")) + return session + + +def test_full_session_produces_valid_core_artifacts(tmp_path: Path): + session = _two_stream_session(tmp_path) + + session.start() + cam = session._streams["cam_a"] # type: ignore[attr-defined] + imu = session._streams["imu_a"] # type: ignore[attr-defined] + assert isinstance(cam, FakeStream) + assert isinstance(imu, FakeStream) + for i in range(10): + cam.push_sample(frame_number=i, capture_ns=1_000_000 * (i + 1)) + for i in range(5): + imu.push_sample(frame_number=i, capture_ns=2_000_000 * (i + 1)) + + report = session.stop() + + # --- sync_point.json -------------------------------------------------- + sp = json.loads((tmp_path / "sync_point.json").read_text()) + assert sp["host_id"] == "rig_01" + assert isinstance(sp["monotonic_ns"], int) + assert isinstance(sp["wall_clock_ns"], int) + assert "sdk_version" in sp + # Silent tone → no chirp fields + assert "chirp_start_ns" not in sp + + # --- manifest.json ---------------------------------------------------- + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["host_id"] == "rig_01" + assert "cam_a" in manifest["streams"] + assert "imu_a" in manifest["streams"] + cam_entry = manifest["streams"]["cam_a"] + assert cam_entry["capabilities"]["provides_audio_track"] is True + assert cam_entry["status"] == "completed" + assert cam_entry["frame_count"] == 10 + + # --- session_log.jsonl ------------------------------------------------ + log_lines = [ + json.loads(line) + for line in (tmp_path / "session_log.jsonl") + .read_text() + .strip() + .split("\n") + ] + transitions = [l for l in log_lines if l["kind"] == "state_transition"] + edges = [(t["from"], t["to"]) for t in transitions] + assert ("idle", "preparing") in edges + assert ("preparing", "recording") in edges + assert ("recording", "stopping") in edges + assert ("stopping", "stopped") in edges + + # --- SessionReport ---------------------------------------------------- + assert report.host_id == "rig_01" + assert len(report.finalizations) == 2 + by_id = {f.stream_id: f for f in report.finalizations} + assert by_id["cam_a"].frame_count == 10 + assert by_id["imu_a"].frame_count == 5 + + +def test_silent_session_omits_chirp_fields(tmp_path: Path): + session = SessionOrchestrator( + host_id="rig_01", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + ) + session.add(FakeStream("cam", provides_audio_track=True)) + session.start() + session.stop() + sp = json.loads((tmp_path / "sync_point.json").read_text()) + assert "chirp_start_ns" not in sp + assert "chirp_stop_ns" not in sp + assert "chirp_spec" not in sp + + +def test_no_audio_stream_single_host_session_works_without_chirp(tmp_path: Path): + session = SessionOrchestrator( + host_id="rig_solo", + output_dir=tmp_path, + sync_tone=SyncToneConfig.default(), # enabled by default + ) + # No audio-capable stream → chirp is skipped silently with an INFO log + session.add(FakeStream("imu_only")) + session.start() + session.stop() + sp = json.loads((tmp_path / "sync_point.json").read_text()) + assert "chirp_start_ns" not in sp + # Session still completes cleanly + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["streams"]["imu_only"]["status"] == "completed" diff --git a/tests/test_capture.py b/tests/test_capture.py deleted file mode 100644 index 3344dc9..0000000 --- a/tests/test_capture.py +++ /dev/null @@ -1,421 +0,0 @@ -"""Tests for syncfield.capture (SyncSession).""" - -import json -import threading -from pathlib import Path - -from syncfield.capture import SyncSession - - -def test_basic_session_flow(tmp_path: Path): - session = SyncSession(host_id="rig_01", output_dir=tmp_path / "out") - sp = session.start() - - assert sp.host_id == "rig_01" - assert sp.monotonic_ns > 0 - - for i in range(5): - session.stamp("cam_left", frame_number=i) - session.stamp("cam_right", frame_number=i) - - counts = session.stop() - assert counts == {"cam_left": 5, "cam_right": 5} - - # Check output files - out = tmp_path / "out" - assert (out / "sync_point.json").exists() - assert (out / "cam_left.timestamps.jsonl").exists() - assert (out / "cam_right.timestamps.jsonl").exists() - - # Verify JSONL content - lines = (out / "cam_left.timestamps.jsonl").read_text().strip().split("\n") - assert len(lines) == 5 - first = json.loads(lines[0]) - assert first["frame_number"] == 0 - assert first["clock_source"] == "host_monotonic" - assert first["clock_domain"] == "rig_01" - - -def test_timestamps_are_monotonically_increasing(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - for i in range(100): - session.stamp("stream", frame_number=i) - - session.stop() - - lines = (tmp_path / "stream.timestamps.jsonl").read_text().strip().split("\n") - timestamps = [json.loads(line)["capture_ns"] for line in lines] - for a, b in zip(timestamps, timestamps[1:]): - assert b >= a, f"Non-monotonic: {a} -> {b}" - - -def test_thread_safety(tmp_path: Path): - """Stamp from multiple threads concurrently.""" - session = SyncSession(host_id="mt", output_dir=tmp_path) - session.start() - - errors: list[Exception] = [] - - def stamp_stream(stream_id: str, count: int) -> None: - try: - for i in range(count): - session.stamp(stream_id, frame_number=i) - except Exception as e: - errors.append(e) - - threads = [ - threading.Thread(target=stamp_stream, args=(f"s{i}", 200)) - for i in range(4) - ] - for t in threads: - t.start() - for t in threads: - t.join() - - counts = session.stop() - assert not errors - assert len(counts) == 4 - for sid, c in counts.items(): - assert c == 200 - - -def test_stamp_before_start_raises(tmp_path: Path): - session = SyncSession(host_id="h", output_dir=tmp_path) - try: - session.stamp("x", frame_number=0) - assert False, "should have raised" - except RuntimeError: - pass - - -def test_double_start_raises(tmp_path: Path): - session = SyncSession(host_id="h", output_dir=tmp_path) - session.start() - try: - session.start() - assert False, "should have raised" - except RuntimeError: - pass - session.stop() - - -def test_custom_uncertainty(tmp_path: Path): - session = SyncSession(host_id="h", output_dir=tmp_path) - session.start() - session.stamp("imu", frame_number=0, uncertainty_ns=1_000_000) - session.stop() - - line = json.loads((tmp_path / "imu.timestamps.jsonl").read_text().strip()) - assert line["uncertainty_ns"] == 1_000_000 - - -def test_sync_point_json_content(tmp_path: Path): - session = SyncSession(host_id="rig_02", output_dir=tmp_path) - sp = session.start() - session.stop() - - data = json.loads((tmp_path / "sync_point.json").read_text()) - assert data["host_id"] == "rig_02" - assert data["monotonic_ns"] == sp.monotonic_ns - assert data["sdk_version"] == "0.1.0" - - -# --- record() tests --- - - -def test_record_basic_flow(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - for i in range(3): - session.record("imu", frame_number=i, channels={"x": float(i)}) - - session.stop() - - ts_path = tmp_path / "imu.timestamps.jsonl" - sensor_path = tmp_path / "imu.jsonl" - assert ts_path.exists() - assert sensor_path.exists() - - ts_lines = ts_path.read_text().strip().split("\n") - sensor_lines = sensor_path.read_text().strip().split("\n") - assert len(ts_lines) == 3 - assert len(sensor_lines) == 3 - - -def test_record_sensor_jsonl_content(tmp_path: Path): - session = SyncSession(host_id="rig_01", output_dir=tmp_path) - session.start() - - session.record("imu", frame_number=0, channels={"accel_x": 0.5, "accel_y": -1.2}) - - session.stop() - - line = json.loads((tmp_path / "imu.jsonl").read_text().strip()) - assert line["channels"] == {"accel_x": 0.5, "accel_y": -1.2} - assert line["capture_ns"] > 0 - assert line["frame_number"] == 0 - assert line["clock_source"] == "host_monotonic" - assert line["clock_domain"] == "rig_01" - assert line["uncertainty_ns"] == 5_000_000 - - -def test_record_timestamps_match_sensor(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - for i in range(5): - session.record("sensor", frame_number=i, channels={"v": float(i)}) - - session.stop() - - ts_lines = (tmp_path / "sensor.timestamps.jsonl").read_text().strip().split("\n") - sensor_lines = (tmp_path / "sensor.jsonl").read_text().strip().split("\n") - - for ts_raw, sensor_raw in zip(ts_lines, sensor_lines): - ts = json.loads(ts_raw) - sensor = json.loads(sensor_raw) - assert ts["capture_ns"] == sensor["capture_ns"] - assert ts["frame_number"] == sensor["frame_number"] - - -def test_record_returns_capture_ns(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - result = session.record("imu", frame_number=0, channels={"x": 1.0}) - - session.stop() - - assert isinstance(result, int) - assert result > 0 - - -def test_record_before_start_raises(tmp_path: Path): - session = SyncSession(host_id="h", output_dir=tmp_path) - try: - session.record("imu", frame_number=0, channels={"x": 1.0}) - assert False, "should have raised" - except RuntimeError: - pass - - -def test_record_thread_safety(tmp_path: Path): - """Record from multiple threads concurrently.""" - session = SyncSession(host_id="mt", output_dir=tmp_path) - session.start() - - errors: list[Exception] = [] - - def record_stream(stream_id: str, count: int) -> None: - try: - for i in range(count): - session.record(stream_id, frame_number=i, channels={"v": float(i)}) - except Exception as e: - errors.append(e) - - threads = [ - threading.Thread(target=record_stream, args=(f"s{i}", 200)) - for i in range(4) - ] - for t in threads: - t.start() - for t in threads: - t.join() - - counts = session.stop() - assert not errors - assert len(counts) == 4 - for sid, c in counts.items(): - assert c == 200 - - -# --- link() tests --- - - -def test_link_basic(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - session.link("cam_left", "/data/video.mp4") - - session.stop() - - manifest = json.loads((tmp_path / "manifest.json").read_text()) - assert "cam_left" in manifest["streams"] - assert manifest["streams"]["cam_left"]["path"] == "/data/video.mp4" - - -def test_link_with_stamp(tmp_path: Path): - """Video pattern: stamp() for timestamps, link() for the file path.""" - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - for i in range(3): - session.stamp("cam_left", frame_number=i) - - session.link("cam_left", "/data/video.mp4") - - session.stop() - - manifest = json.loads((tmp_path / "manifest.json").read_text()) - entry = manifest["streams"]["cam_left"] - assert entry["type"] == "video" - assert entry["path"] == "/data/video.mp4" - assert entry["timestamps_path"] == "cam_left.timestamps.jsonl" - - -# --- manifest tests --- - - -def test_manifest_written_on_stop(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - session.stamp("cam", frame_number=0) - session.stop() - - assert (tmp_path / "manifest.json").exists() - - -def test_manifest_sensor_stream(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - session.record("imu", frame_number=0, channels={"x": 1.0}) - - session.stop() - - manifest = json.loads((tmp_path / "manifest.json").read_text()) - entry = manifest["streams"]["imu"] - assert entry["type"] == "sensor" - assert entry["sensor_path"] == "imu.jsonl" - assert entry["timestamps_path"] == "imu.timestamps.jsonl" - - -def test_manifest_video_stream(tmp_path: Path): - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - for i in range(3): - session.stamp("cam", frame_number=i) - session.link("cam", "/data/cam.mp4") - - session.stop() - - manifest = json.loads((tmp_path / "manifest.json").read_text()) - entry = manifest["streams"]["cam"] - assert entry["type"] == "video" - assert entry["path"] == "/data/cam.mp4" - assert entry["timestamps_path"] == "cam.timestamps.jsonl" - - -def test_manifest_mixed_streams(tmp_path: Path): - """Session with both video (stamp+link) and sensor (record) streams.""" - session = SyncSession(host_id="rig_01", output_dir=tmp_path) - session.start() - - # Video stream: stamp + link - for i in range(5): - session.stamp("cam_left", frame_number=i) - session.link("cam_left", "/data/cam_left.mp4") - - # Sensor stream: record - for i in range(10): - session.record("imu", frame_number=i, channels={"ax": float(i), "ay": 0.0}) - - counts = session.stop() - - manifest = json.loads((tmp_path / "manifest.json").read_text()) - assert manifest["host_id"] == "rig_01" - assert "sdk_version" in manifest - - # Video stream checks - cam = manifest["streams"]["cam_left"] - assert cam["type"] == "video" - assert cam["path"] == "/data/cam_left.mp4" - assert cam["timestamps_path"] == "cam_left.timestamps.jsonl" - assert cam["frame_count"] == 5 - - # Sensor stream checks - imu = manifest["streams"]["imu"] - assert imu["type"] == "sensor" - assert imu["sensor_path"] == "imu.jsonl" - assert imu["timestamps_path"] == "imu.timestamps.jsonl" - assert imu["frame_count"] == 10 - - # Counts from stop() - assert counts["cam_left"] == 5 - assert counts["imu"] == 10 - - -def test_stamp_with_capture_ns(tmp_path: Path): - """stamp() accepts a pre-captured timestamp.""" - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - import time - - pre_captured = time.monotonic_ns() - result = session.stamp("cam", frame_number=0, capture_ns=pre_captured) - - session.stop() - - assert result == pre_captured - - lines = (tmp_path / "cam.timestamps.jsonl").read_text().strip().splitlines() - entry = json.loads(lines[0]) - assert entry["capture_ns"] == pre_captured - - -def test_record_with_capture_ns(tmp_path: Path): - """record() accepts a pre-captured timestamp.""" - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - import time - - pre_captured = time.monotonic_ns() - result = session.record( - "imu", frame_number=0, channels={"x": 1.0}, capture_ns=pre_captured, - ) - - session.stop() - - assert result == pre_captured - - # Both files should have the same pre-captured timestamp - ts_line = json.loads( - (tmp_path / "imu.timestamps.jsonl").read_text().strip().splitlines()[0] - ) - sensor_line = json.loads( - (tmp_path / "imu.jsonl").read_text().strip().splitlines()[0] - ) - assert ts_line["capture_ns"] == pre_captured - assert sensor_line["capture_ns"] == pre_captured - - -def test_record_nested_channels(tmp_path: Path): - """record() accepts nested/complex channel data.""" - session = SyncSession(host_id="h1", output_dir=tmp_path) - session.start() - - hand_state = { - "joints": { - "wrist": [0.1, 0.2, 0.3], - "thumb_tip": [0.4, 0.5, 0.6], - }, - "gestures": {"pinch": 0.95, "fist": 0.02}, - "finger_angles": [12.5, 45.0, 30.0, 15.0, 5.0], - } - session.record("hand_tracker", frame_number=0, channels=hand_state) - - session.stop() - - line = json.loads( - (tmp_path / "hand_tracker.jsonl").read_text().strip().splitlines()[0] - ) - assert line["channels"]["joints"]["wrist"] == [0.1, 0.2, 0.3] - assert line["channels"]["gestures"]["pinch"] == 0.95 - assert line["channels"]["finger_angles"] == [12.5, 45.0, 30.0, 15.0, 5.0] diff --git a/tests/test_writer.py b/tests/test_writer.py deleted file mode 100644 index f906c31..0000000 --- a/tests/test_writer.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Tests for syncfield.writer.""" - -import json -from pathlib import Path - -from syncfield.types import FrameTimestamp, SensorSample, SyncPoint -from syncfield.writer import SensorWriter, StreamWriter, write_manifest, write_sync_point - - -def test_stream_writer_creates_jsonl(tmp_path: Path): - w = StreamWriter("cam_left", tmp_path) - w.open() - for i in range(3): - w.write(FrameTimestamp(frame_number=i, capture_ns=1000 + i, clock_domain="h1")) - w.close() - - path = tmp_path / "cam_left.timestamps.jsonl" - assert path.exists() - - lines = path.read_text().strip().split("\n") - assert len(lines) == 3 - - first = json.loads(lines[0]) - assert first["frame_number"] == 0 - assert first["capture_ns"] == 1000 - assert first["clock_source"] == "host_monotonic" - assert first["clock_domain"] == "h1" - - -def test_stream_writer_count(tmp_path: Path): - w = StreamWriter("imu", tmp_path) - w.open() - assert w.count == 0 - w.write(FrameTimestamp(frame_number=0, capture_ns=100)) - w.write(FrameTimestamp(frame_number=1, capture_ns=200)) - assert w.count == 2 - w.close() - - -def test_write_sync_point(tmp_path: Path): - sp = SyncPoint( - monotonic_ns=111, - wall_clock_ns=222, - host_id="test", - timestamp_ms=333, - iso_datetime="2024-01-01T00:00:00", - ) - path = write_sync_point(sp, tmp_path) - assert path == tmp_path / "sync_point.json" - - data = json.loads(path.read_text()) - assert data["sdk_version"] == "0.1.0" - assert data["host_id"] == "test" - assert data["monotonic_ns"] == 111 - assert data["wall_clock_ns"] == 222 - - -def test_stream_writer_raises_if_not_open(tmp_path: Path): - w = StreamWriter("x", tmp_path) - try: - w.write(FrameTimestamp(frame_number=0, capture_ns=1)) - assert False, "should have raised" - except RuntimeError: - pass - - -# --- SensorWriter tests --- - - -def test_sensor_writer_creates_jsonl(tmp_path: Path): - w = SensorWriter("imu", tmp_path) - w.open() - for i in range(3): - w.write(SensorSample( - frame_number=i, - capture_ns=1000 + i, - channels={"accel_x": float(i), "accel_y": float(i * 2)}, - clock_domain="h1", - )) - w.close() - - path = tmp_path / "imu.jsonl" - assert path.exists() - - lines = path.read_text().strip().split("\n") - assert len(lines) == 3 - - first = json.loads(lines[0]) - assert first["frame_number"] == 0 - assert first["capture_ns"] == 1000 - assert first["channels"] == {"accel_x": 0.0, "accel_y": 0.0} - assert first["clock_domain"] == "h1" - - -def test_sensor_writer_count(tmp_path: Path): - w = SensorWriter("sensor", tmp_path) - w.open() - assert w.count == 0 - w.write(SensorSample(frame_number=0, capture_ns=100, channels={"v": 1.0})) - w.write(SensorSample(frame_number=1, capture_ns=200, channels={"v": 2.0})) - assert w.count == 2 - w.close() - - -def test_sensor_writer_raises_if_not_open(tmp_path: Path): - w = SensorWriter("x", tmp_path) - try: - w.write(SensorSample(frame_number=0, capture_ns=1, channels={"v": 0.0})) - assert False, "should have raised" - except RuntimeError: - pass - - -def test_write_manifest(tmp_path: Path): - streams = { - "cam_left": {"type": "video", "timestamps_path": "cam_left.timestamps.jsonl"}, - } - path = write_manifest("test_host", streams, tmp_path) - assert path == tmp_path / "manifest.json" - - data = json.loads(path.read_text()) - assert data["sdk_version"] == "0.1.0" - assert data["host_id"] == "test_host" - assert "streams" in data - assert data["streams"]["cam_left"]["type"] == "video" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/adapters/__init__.py b/tests/unit/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/adapters/test_ble_imu.py b/tests/unit/adapters/test_ble_imu.py new file mode 100644 index 0000000..2287f8f --- /dev/null +++ b/tests/unit/adapters/test_ble_imu.py @@ -0,0 +1,124 @@ +"""Unit tests for BLEImuGenericStream using a mocked bleak module.""" + +from __future__ import annotations + +import importlib +import struct +import sys +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from syncfield.clock import SessionClock +from syncfield.types import HealthEventKind, SyncPoint + + +def _clock() -> SessionClock: + return SessionClock(sync_point=SyncPoint.create_now("h")) + + +@pytest.fixture +def mock_bleak(monkeypatch): + fake = MagicMock() + client = MagicMock() + client.connect = AsyncMock() + client.disconnect = AsyncMock() + client.start_notify = AsyncMock() + client.stop_notify = AsyncMock() + fake.BleakClient.return_value = client + monkeypatch.setitem(sys.modules, "bleak", fake) + sys.modules.pop("syncfield.adapters.ble_imu", None) + importlib.import_module("syncfield.adapters.ble_imu") + yield fake, client + sys.modules.pop("syncfield.adapters.ble_imu", None) + + +def test_capabilities(mock_bleak): + from syncfield.adapters.ble_imu import BLEImuGenericStream + stream = BLEImuGenericStream( + "imu", mac="AA:BB:CC:DD:EE:FF", characteristic_uuid="1234" + ) + assert stream.kind == "sensor" + assert stream.capabilities.provides_audio_track is False + assert stream.capabilities.is_removable is True + assert stream.capabilities.produces_file is False + + +def test_prepare_instantiates_client(mock_bleak): + fake, _ = mock_bleak + from syncfield.adapters.ble_imu import BLEImuGenericStream + stream = BLEImuGenericStream( + "imu", mac="00:11:22:33:44:55", characteristic_uuid="c" + ) + stream.prepare() + fake.BleakClient.assert_called_once_with("00:11:22:33:44:55") + + +def test_channel_name_length_must_match_format(mock_bleak): + from syncfield.adapters.ble_imu import BLEImuGenericStream + with pytest.raises(ValueError, match="channel_names"): + BLEImuGenericStream( + "imu", + mac="m", + characteristic_uuid="c", + frame_format="= 1 + assert client.start_notify.await_count >= 1 + + +def test_notification_payload_is_decoded_and_emitted(mock_bleak): + from syncfield.adapters.ble_imu import BLEImuGenericStream + stream = BLEImuGenericStream( + "imu", + mac="m", + characteristic_uuid="c", + frame_format=" SessionClock: + return SessionClock(sync_point=SyncPoint.create_now("h")) + + +def test_capabilities(): + stream = JSONLFileStream("log", file_path="/tmp/log.jsonl") + assert stream.capabilities.produces_file is True + assert stream.capabilities.provides_audio_track is False + assert stream.kind == "sensor" + + +def test_lifecycle_reports_known_path_and_counts_lines(tmp_path): + log_path = tmp_path / "custom.jsonl" + log_path.write_text( + json.dumps({"frame_number": 0, "capture_ns": 1}) + "\n" + + json.dumps({"frame_number": 1, "capture_ns": 2}) + "\n" + ) + stream = JSONLFileStream("custom", file_path=log_path) + stream.prepare() + stream.start(_clock()) + report = stream.stop() + assert report.status == "completed" + assert report.file_path == log_path + assert report.frame_count == 2 + + +def test_missing_file_returns_partial_status(tmp_path): + stream = JSONLFileStream("missing", file_path=tmp_path / "nope.jsonl") + stream.prepare() + stream.start(_clock()) + report = stream.stop() + assert report.status == "partial" + assert report.frame_count == 0 + assert report.file_path is None + + +def test_start_without_prepare_raises(): + stream = JSONLFileStream("x", file_path="/tmp/x.jsonl") + with pytest.raises(RuntimeError, match="prepare"): + stream.start(_clock()) + + +def test_empty_file_is_completed_with_zero_frames(tmp_path): + p = tmp_path / "empty.jsonl" + p.write_text("") + stream = JSONLFileStream("empty", file_path=p) + stream.prepare() + stream.start(_clock()) + report = stream.stop() + assert report.status == "completed" + assert report.frame_count == 0 + assert report.file_path == p diff --git a/tests/unit/adapters/test_uvc_webcam.py b/tests/unit/adapters/test_uvc_webcam.py new file mode 100644 index 0000000..ef474e6 --- /dev/null +++ b/tests/unit/adapters/test_uvc_webcam.py @@ -0,0 +1,105 @@ +"""Unit tests for UVCWebcamStream using a mocked cv2 module.""" + +from __future__ import annotations + +import importlib +import sys +import time +from unittest.mock import MagicMock + +import pytest + +from syncfield.clock import SessionClock +from syncfield.types import SyncPoint + + +def _clock() -> SessionClock: + return SessionClock(sync_point=SyncPoint.create_now("h")) + + +def _build_fake_cv2(frame_budget: int = 3) -> MagicMock: + """Return a MagicMock that looks enough like cv2 for the adapter.""" + fake = MagicMock() + cap = MagicMock() + counter = {"n": 0} + + def fake_read(): + counter["n"] += 1 + if counter["n"] <= frame_budget: + return True, MagicMock(shape=(480, 640, 3)) + return False, None + + cap.read.side_effect = fake_read + cap.isOpened.return_value = True + + def fake_get(prop): + if prop == fake.CAP_PROP_FPS: + return 30.0 + if prop == fake.CAP_PROP_FRAME_WIDTH: + return 640 + if prop == fake.CAP_PROP_FRAME_HEIGHT: + return 480 + return 0.0 + + cap.get.side_effect = fake_get + fake.VideoCapture.return_value = cap + fake.CAP_PROP_FPS = "CAP_PROP_FPS" + fake.CAP_PROP_FRAME_WIDTH = "CAP_PROP_FRAME_WIDTH" + fake.CAP_PROP_FRAME_HEIGHT = "CAP_PROP_FRAME_HEIGHT" + fake.VideoWriter_fourcc = lambda *args: 0 + fake.VideoWriter.return_value = MagicMock() + return fake + + +@pytest.fixture +def mock_cv2(monkeypatch): + fake = _build_fake_cv2() + monkeypatch.setitem(sys.modules, "cv2", fake) + # Force re-import so the adapter binds to the fake module + sys.modules.pop("syncfield.adapters.uvc_webcam", None) + importlib.import_module("syncfield.adapters.uvc_webcam") + yield fake + sys.modules.pop("syncfield.adapters.uvc_webcam", None) + + +def test_capabilities(mock_cv2, tmp_path): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + stream = UVCWebcamStream("cam", device_index=0, output_dir=tmp_path) + assert stream.capabilities.produces_file is True + assert stream.capabilities.provides_audio_track is False + assert stream.kind == "video" + + +def test_prepare_opens_device(mock_cv2, tmp_path): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + stream = UVCWebcamStream("cam", device_index=0, output_dir=tmp_path) + stream.prepare() + mock_cv2.VideoCapture.assert_called_once_with(0) + + +def test_prepare_raises_when_device_fails_to_open(mock_cv2, tmp_path): + mock_cv2.VideoCapture.return_value.isOpened.return_value = False + from syncfield.adapters.uvc_webcam import UVCWebcamStream + stream = UVCWebcamStream("cam", device_index=0, output_dir=tmp_path) + with pytest.raises(RuntimeError, match="VideoCapture"): + stream.prepare() + + +def test_start_stop_produces_file_path_in_report(mock_cv2, tmp_path): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + stream = UVCWebcamStream("cam", device_index=0, output_dir=tmp_path) + stream.prepare() + stream.start(_clock()) + # Let the background thread read the mocked frames + time.sleep(0.1) + report = stream.stop() + assert report.status == "completed" + assert report.file_path is not None + assert report.frame_count >= 1 + + +def test_cv2_missing_raises_clear_install_hint(monkeypatch): + monkeypatch.setitem(sys.modules, "cv2", None) + sys.modules.pop("syncfield.adapters.uvc_webcam", None) + with pytest.raises(ImportError, match=r"syncfield\[uvc\]"): + importlib.import_module("syncfield.adapters.uvc_webcam") diff --git a/tests/unit/test_clock.py b/tests/unit/test_clock.py new file mode 100644 index 0000000..e6ac742 --- /dev/null +++ b/tests/unit/test_clock.py @@ -0,0 +1,46 @@ +"""Tests for SessionClock — the immutable clock handle passed to Streams.""" + +from __future__ import annotations + +import dataclasses +import time + +import pytest + +from syncfield.clock import SessionClock +from syncfield.types import SyncPoint + + +def _make_clock(host_id: str = "host_01") -> SessionClock: + return SessionClock(sync_point=SyncPoint.create_now(host_id)) + + +def test_session_clock_holds_sync_point(): + sp = SyncPoint.create_now("host_01") + clock = SessionClock(sync_point=sp) + assert clock.sync_point is sp + assert clock.host_id == "host_01" + + +def test_now_ns_is_monotonic(): + clock = _make_clock() + t1 = clock.now_ns() + t2 = clock.now_ns() + assert t2 >= t1 + # Distance from the real monotonic clock should be negligible + assert abs(t2 - time.monotonic_ns()) < 10_000_000 # 10 ms slack + + +def test_elapsed_ns_from_start(): + clock = _make_clock() + time.sleep(0.005) + elapsed = clock.elapsed_ns() + assert elapsed >= 4_000_000 # at least 4 ms + assert elapsed < 100_000_000 # but well under 100 ms + + +def test_session_clock_is_frozen_dataclass(): + clock = _make_clock() + assert dataclasses.is_dataclass(clock) + with pytest.raises(dataclasses.FrozenInstanceError): + clock.sync_point = SyncPoint.create_now("other") # type: ignore[misc] diff --git a/tests/unit/test_fake_stream.py b/tests/unit/test_fake_stream.py new file mode 100644 index 0000000..7d9e9f2 --- /dev/null +++ b/tests/unit/test_fake_stream.py @@ -0,0 +1,86 @@ +"""Tests for the FakeStream test utility.""" + +from __future__ import annotations + +import pytest + +from syncfield.clock import SessionClock +from syncfield.stream import Stream +from syncfield.testing import FakeStream +from syncfield.types import HealthEvent, HealthEventKind, SampleEvent, SyncPoint + + +def _clock() -> SessionClock: + return SessionClock(sync_point=SyncPoint.create_now("h")) + + +def test_fake_stream_satisfies_stream_protocol(): + assert isinstance(FakeStream("cam"), Stream) + + +def test_lifecycle_call_counts(): + fs = FakeStream("cam") + fs.prepare() + assert fs.prepare_calls == 1 + fs.start(_clock()) + assert fs.start_calls == 1 + report = fs.stop() + assert fs.stop_calls == 1 + assert report.status == "completed" + assert report.frame_count == 0 + + +def test_push_sample_routes_to_callback_and_counts(): + fs = FakeStream("cam") + fs.prepare() + fs.start(_clock()) + received: list[SampleEvent] = [] + fs.on_sample(received.append) + fs.push_sample(frame_number=0, capture_ns=1000) + fs.push_sample(frame_number=1, capture_ns=2000) + report = fs.stop() + assert len(received) == 2 + assert received[0].frame_number == 0 + assert report.frame_count == 2 + assert report.first_sample_at_ns == 1000 + assert report.last_sample_at_ns == 2000 + + +def test_push_health_routes_to_callback(): + fs = FakeStream("cam") + fs.prepare() + fs.start(_clock()) + received: list[HealthEvent] = [] + fs.on_health(received.append) + fs.push_health(HealthEventKind.WARNING, at_ns=42, detail="test") + fs.stop() + assert len(received) == 1 + assert received[0].kind is HealthEventKind.WARNING + assert received[0].detail == "test" + + +def test_fail_on_prepare_raises(): + fs = FakeStream("cam", fail_on_prepare=True) + with pytest.raises(RuntimeError, match="fake failure"): + fs.prepare() + + +def test_fail_on_start_raises(): + fs = FakeStream("cam", fail_on_start=True) + fs.prepare() + with pytest.raises(RuntimeError, match="fake failure"): + fs.start(_clock()) + + +def test_fail_on_stop_returns_failed_report(): + fs = FakeStream("cam", fail_on_stop=True) + fs.prepare() + fs.start(_clock()) + report = fs.stop() + assert report.status == "failed" + assert report.error is not None + + +def test_audio_capability_flag(): + fs = FakeStream("cam", provides_audio_track=True) + assert fs.capabilities.provides_audio_track is True diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py new file mode 100644 index 0000000..cff2e11 --- /dev/null +++ b/tests/unit/test_orchestrator.py @@ -0,0 +1,397 @@ +"""Tests for SessionOrchestrator lifecycle and behavior.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest + +from syncfield.clock import SessionClock +from syncfield.orchestrator import SessionOrchestrator +from syncfield.testing import FakeStream +from syncfield.tone import ChirpPlayer, ChirpSpec, SyncToneConfig +from syncfield.types import HealthEventKind, SessionState + + +def _fast_chirp_config() -> SyncToneConfig: + """Very short chirp + margins — keeps orchestrator tests snappy.""" + return SyncToneConfig( + enabled=True, + start_chirp=ChirpSpec(400, 2500, 10, 0.8, 2), + stop_chirp=ChirpSpec(2500, 400, 10, 0.8, 2), + post_start_stabilization_ms=5, + pre_stop_tail_margin_ms=5, + ) + + +def _session(tmp_path, **kwargs) -> SessionOrchestrator: + """Construct a silent-chirp session for concise test setup.""" + return SessionOrchestrator( + host_id=kwargs.pop("host_id", "rig_01"), + output_dir=tmp_path, + sync_tone=kwargs.pop("sync_tone", SyncToneConfig.silent()), + **kwargs, + ) + + +class TestConstruction: + def test_initial_state_is_idle(self, tmp_path): + assert _session(tmp_path).state is SessionState.IDLE + + def test_host_id_property(self, tmp_path): + assert _session(tmp_path, host_id="rig_42").host_id == "rig_42" + + def test_output_dir_created(self, tmp_path): + target = tmp_path / "sub" / "dir" + assert not target.exists() + SessionOrchestrator( + host_id="h", + output_dir=target, + sync_tone=SyncToneConfig.silent(), + ) + assert target.exists() + + +class TestAdd: + def test_add_stream_in_idle_state(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("cam")) + assert session.state is SessionState.IDLE # add does not change state + + def test_rejects_duplicate_stream_id(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("cam")) + with pytest.raises(ValueError, match="duplicate stream id"): + session.add(FakeStream("cam")) + + +class TestStartHappyPath: + def test_start_transitions_to_recording(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("cam")) + session.start() + assert session.state is SessionState.RECORDING + + def test_start_calls_prepare_then_start_on_each_stream(self, tmp_path): + session = _session(tmp_path) + fs1 = FakeStream("a") + fs2 = FakeStream("b") + session.add(fs1) + session.add(fs2) + session.start() + assert fs1.prepare_calls == 1 + assert fs1.start_calls == 1 + assert fs2.prepare_calls == 1 + assert fs2.start_calls == 1 + + def test_start_cannot_be_called_twice(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("x")) + session.start() + with pytest.raises(RuntimeError, match="start.*recording"): + session.start() + + def test_start_requires_at_least_one_stream(self, tmp_path): + session = _session(tmp_path) + with pytest.raises(RuntimeError, match="no streams"): + session.start() + + def test_session_clock_shared_across_streams(self, tmp_path): + """All streams must see the exact same sync point instance.""" + session = _session(tmp_path) + + clocks: list[SessionClock] = [] + + class RecordingStream(FakeStream): + def start(self, session_clock): # type: ignore[override] + clocks.append(session_clock) + super().start(session_clock) + + session.add(RecordingStream("a")) + session.add(RecordingStream("b")) + session.start() + assert len(clocks) == 2 + assert clocks[0].sync_point is clocks[1].sync_point + assert clocks[0].host_id == "rig_01" + + +class TestStartRollback: + def test_failure_during_start_rolls_back_prior_streams(self, tmp_path): + session = _session(tmp_path) + good1 = FakeStream("a") + bad = FakeStream("b", fail_on_start=True) + good2 = FakeStream("c") + session.add(good1) + session.add(bad) + session.add(good2) + + with pytest.raises(RuntimeError, match="fake failure in start"): + session.start() + + # good1 was started → must be rolled back (stop called) + assert good1.start_calls == 1 + assert good1.stop_calls == 1 + # bad raised during start → stop should NOT be called on it + assert bad.start_calls == 1 + assert bad.stop_calls == 0 + # good2 never reached start + assert good2.start_calls == 0 + assert good2.stop_calls == 0 + + assert session.state is SessionState.IDLE + + def test_failure_during_prepare_stops_earlier_streams(self, tmp_path): + session = _session(tmp_path) + good = FakeStream("a") + bad = FakeStream("b", fail_on_prepare=True) + session.add(good) + session.add(bad) + + with pytest.raises(RuntimeError, match="fake failure in prepare"): + session.start() + + assert good.prepare_calls == 1 + assert good.start_calls == 1 # fully started + assert good.stop_calls == 1 # then rolled back + assert bad.prepare_calls == 1 + assert bad.start_calls == 0 # never reached start + + assert session.state is SessionState.IDLE + + +class TestStop: + def test_stop_transitions_to_stopped(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("a")) + session.start() + report = session.stop() + assert session.state is SessionState.STOPPED + assert report.host_id == "rig_01" + + def test_stop_calls_stop_on_every_stream(self, tmp_path): + session = _session(tmp_path) + fs1 = FakeStream("a") + fs2 = FakeStream("b") + session.add(fs1) + session.add(fs2) + session.start() + session.stop() + assert fs1.stop_calls == 1 + assert fs2.stop_calls == 1 + + def test_stop_collects_finalization_reports(self, tmp_path): + session = _session(tmp_path) + fs1 = FakeStream("a") + fs2 = FakeStream("b") + session.add(fs1) + session.add(fs2) + session.start() + fs1.push_sample(0, 100) + fs1.push_sample(1, 200) + report = session.stop() + by_id = {r.stream_id: r for r in report.finalizations} + assert by_id["a"].frame_count == 2 + assert by_id["a"].first_sample_at_ns == 100 + assert by_id["a"].last_sample_at_ns == 200 + assert by_id["b"].frame_count == 0 + + def test_stop_writes_sync_point_json(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("a")) + session.start() + session.stop() + sp = json.loads((tmp_path / "sync_point.json").read_text()) + assert sp["host_id"] == "rig_01" + assert "monotonic_ns" in sp + # Silent mode → no chirp fields + assert "chirp_start_ns" not in sp + + def test_stop_writes_manifest_with_capabilities(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("a", provides_audio_track=True)) + session.start() + session.stop() + m = json.loads((tmp_path / "manifest.json").read_text()) + assert m["host_id"] == "rig_01" + assert "a" in m["streams"] + assert m["streams"]["a"]["capabilities"]["provides_audio_track"] is True + assert m["streams"]["a"]["status"] == "completed" + assert m["streams"]["a"]["frame_count"] == 0 + + def test_stop_requires_recording_state(self, tmp_path): + session = _session(tmp_path) + with pytest.raises(RuntimeError, match="stop.*idle"): + session.stop() + + def test_failing_stream_does_not_block_other_stops(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("good")) + session.add(FakeStream("bad", fail_on_stop=True)) + session.start() + report = session.stop() + by_id = {r.stream_id: r for r in report.finalizations} + assert by_id["good"].status == "completed" + assert by_id["bad"].status == "failed" + # Session still reaches STOPPED state — stop() is best-effort + assert session.state is SessionState.STOPPED + + +class TestChirpIntegration: + def test_chirp_skipped_when_no_audio_capable_stream(self, tmp_path, caplog): + player = MagicMock(spec=ChirpPlayer) + player.is_silent.return_value = False + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=SyncToneConfig.default(), + chirp_player=player, + ) + session.add(FakeStream("a", provides_audio_track=False)) + with caplog.at_level("INFO", logger="syncfield.orchestrator"): + session.start() + session.stop() + player.play.assert_not_called() + assert "cannot participate" in caplog.text.lower() + + def test_chirp_played_when_audio_capable_stream_exists(self, tmp_path): + player = MagicMock(spec=ChirpPlayer) + player.is_silent.return_value = False + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=_fast_chirp_config(), + chirp_player=player, + ) + session.add(FakeStream("a", provides_audio_track=True)) + session.start() + session.stop() + assert player.play.call_count == 2 # start + stop chirp + + def test_silent_tone_never_plays_chirp(self, tmp_path): + player = MagicMock(spec=ChirpPlayer) + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + chirp_player=player, + ) + session.add(FakeStream("a", provides_audio_track=True)) + session.start() + session.stop() + player.play.assert_not_called() + + def test_chirp_fields_written_to_sync_point_json(self, tmp_path): + player = MagicMock(spec=ChirpPlayer) + player.is_silent.return_value = False + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=_fast_chirp_config(), + chirp_player=player, + ) + session.add(FakeStream("a", provides_audio_track=True)) + session.start() + session.stop() + sp = json.loads((tmp_path / "sync_point.json").read_text()) + assert "chirp_start_ns" in sp + assert "chirp_stop_ns" in sp + assert sp["chirp_start_ns"] > 0 + assert sp["chirp_stop_ns"] > sp["chirp_start_ns"] + assert sp["chirp_spec"]["from_hz"] == 400 + + def test_session_report_carries_chirp_timestamps(self, tmp_path): + player = MagicMock(spec=ChirpPlayer) + player.is_silent.return_value = False + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=_fast_chirp_config(), + chirp_player=player, + ) + session.add(FakeStream("a", provides_audio_track=True)) + session.start() + report = session.stop() + assert report.chirp_start_ns is not None + assert report.chirp_stop_ns is not None + + +class TestSessionLog: + def test_session_log_captures_state_transitions(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("a")) + session.start() + session.stop() + + log_path = tmp_path / "session_log.jsonl" + assert log_path.exists() + lines = [json.loads(l) for l in log_path.read_text().strip().split("\n")] + transitions = [l for l in lines if l["kind"] == "state_transition"] + edges = {(t["from"], t["to"]) for t in transitions} + assert ("idle", "preparing") in edges + assert ("preparing", "recording") in edges + assert ("recording", "stopping") in edges + assert ("stopping", "stopped") in edges + + def test_session_log_flushes_during_recording(self, tmp_path): + """A crash between start() and stop() must leave a readable log.""" + session = _session(tmp_path) + session.add(FakeStream("a")) + session.start() + # Simulate "read the log while still RECORDING" + content = (tmp_path / "session_log.jsonl").read_text() + assert "preparing" in content + assert "recording" in content + session.stop() + + def test_rollback_is_logged(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("a")) + session.add(FakeStream("b", fail_on_start=True)) + with pytest.raises(RuntimeError): + session.start() + + log_path = tmp_path / "session_log.jsonl" + assert log_path.exists() + lines = [json.loads(l) for l in log_path.read_text().strip().split("\n")] + assert any(l["kind"] == "rollback" for l in lines) + + +class TestHealthRouting: + def test_stream_health_events_routed_to_session_log(self, tmp_path): + session = _session(tmp_path) + fs = FakeStream("a") + session.add(fs) + session.start() + fs.push_health(HealthEventKind.DROP, at_ns=500, detail="buffer full") + fs.push_health(HealthEventKind.RECONNECT, at_ns=600) + session.stop() + + lines = [ + json.loads(l) + for l in (tmp_path / "session_log.jsonl").read_text().strip().split("\n") + ] + health_lines = [l for l in lines if l["kind"] == "health"] + assert len(health_lines) == 2 + assert health_lines[0]["stream_id"] == "a" + assert health_lines[0]["health_kind"] == "drop" + assert health_lines[0]["detail"] == "buffer full" + assert health_lines[1]["health_kind"] == "reconnect" + + def test_health_emitted_before_start_is_buffered_not_logged(self, tmp_path): + """Before start(), the session log isn't open yet — health events + must still reach the FinalizationReport via the StreamBase buffer. + """ + session = _session(tmp_path) + fs = FakeStream("a") + session.add(fs) + # Session log not yet open + fs.push_health(HealthEventKind.WARNING, at_ns=1, detail="early") + session.start() + report = session.stop() + + final = next(f for f in report.finalizations if f.stream_id == "a") + assert any( + h.kind is HealthEventKind.WARNING and h.detail == "early" + for h in final.health_events + ) diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py new file mode 100644 index 0000000..7c94c52 --- /dev/null +++ b/tests/unit/test_public_api.py @@ -0,0 +1,37 @@ +"""Tests for the public import surface of the syncfield package.""" + +from __future__ import annotations + + +def test_top_level_exports(): + import syncfield as sf + # Core orchestrator API + assert hasattr(sf, "SessionOrchestrator") + assert hasattr(sf, "SyncToneConfig") + assert hasattr(sf, "ChirpSpec") + # Protocol + base class for adapter authors + assert hasattr(sf, "Stream") + assert hasattr(sf, "StreamBase") + # Key types + assert hasattr(sf, "StreamCapabilities") + assert hasattr(sf, "SessionState") + assert hasattr(sf, "SyncPoint") + # Clock + assert hasattr(sf, "SessionClock") + # Version + assert hasattr(sf, "__version__") + + +def test_testing_subpackage(): + from syncfield.testing import FakeStream + assert FakeStream("x").id == "x" + + +def test_adapters_subpackage_jsonl_always_importable(): + from syncfield.adapters import JSONLFileStream + assert JSONLFileStream is not None + + +def test_no_old_sync_session_export(): + import syncfield as sf + assert not hasattr(sf, "SyncSession") diff --git a/tests/unit/test_stream.py b/tests/unit/test_stream.py new file mode 100644 index 0000000..8237f82 --- /dev/null +++ b/tests/unit/test_stream.py @@ -0,0 +1,111 @@ +"""Tests for the Stream protocol and StreamBase helper class.""" + +from __future__ import annotations + +from typing import List + +from syncfield.clock import SessionClock +from syncfield.stream import Stream, StreamBase +from syncfield.types import ( + FinalizationReport, + HealthEvent, + HealthEventKind, + SampleEvent, + StreamCapabilities, + SyncPoint, +) + + +class _DemoStream(StreamBase): + """Minimal concrete Stream used to exercise the base class behavior.""" + + def __init__(self, id: str) -> None: + super().__init__( + id=id, + kind="sensor", + capabilities=StreamCapabilities(supports_precise_timestamps=True), + ) + self.prepared = False + self.started = False + self.stopped = False + self._clock: SessionClock | None = None + + def prepare(self) -> None: + self.prepared = True + + def start(self, session_clock: SessionClock) -> None: + self.started = True + self._clock = session_clock + + def stop(self) -> FinalizationReport: + self.stopped = True + return FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=0, + file_path=None, + first_sample_at_ns=None, + last_sample_at_ns=None, + health_events=list(self._collected_health), + error=None, + ) + + +def _clock() -> SessionClock: + return SessionClock(sync_point=SyncPoint.create_now("h")) + + +def test_stream_protocol_is_runtime_checkable(): + assert isinstance(_DemoStream("x"), Stream) + + +def test_demo_stream_lifecycle(): + demo = _DemoStream("x") + demo.prepare() + assert demo.prepared + demo.start(_clock()) + assert demo.started + report = demo.stop() + assert demo.stopped + assert report.stream_id == "x" + assert report.status == "completed" + + +def test_stream_base_routes_sample_events_to_callback(): + demo = _DemoStream("x") + received: List[SampleEvent] = [] + demo.on_sample(received.append) + ev = SampleEvent(stream_id="x", frame_number=1, capture_ns=1000) + demo._emit_sample(ev) + assert received == [ev] + + +def test_stream_base_routes_health_to_callback_and_buffer(): + demo = _DemoStream("x") + received: List[HealthEvent] = [] + demo.on_health(received.append) + ev = HealthEvent("x", HealthEventKind.HEARTBEAT, at_ns=100) + demo._emit_health(ev) + assert received == [ev] + # Also accumulated internally for inclusion in FinalizationReport + report = demo.stop() + assert ev in report.health_events + + +def test_stream_base_supports_multiple_sample_callbacks(): + demo = _DemoStream("x") + calls_a: List[SampleEvent] = [] + calls_b: List[SampleEvent] = [] + demo.on_sample(calls_a.append) + demo.on_sample(calls_b.append) + ev = SampleEvent("x", 0, 0) + demo._emit_sample(ev) + assert calls_a == [ev] + assert calls_b == [ev] + + +def test_stream_base_exposes_id_kind_capabilities(): + demo = _DemoStream("sensor_42") + assert demo.id == "sensor_42" + assert demo.kind == "sensor" + assert demo.capabilities.supports_precise_timestamps is True diff --git a/tests/unit/test_tone.py b/tests/unit/test_tone.py new file mode 100644 index 0000000..7925313 --- /dev/null +++ b/tests/unit/test_tone.py @@ -0,0 +1,189 @@ +"""Tests for sync tone generation, serialization, and playback.""" + +from __future__ import annotations + +import dataclasses +import struct +import sys +import wave +from unittest.mock import MagicMock, patch + +import pytest + +from syncfield.tone import ( + ChirpPlayer, + SilentChirpPlayer, + SoundDeviceChirpPlayer, + SyncToneConfig, + create_default_player, + generate_chirp_samples, + write_chirp_wav, +) +from syncfield.types import ChirpSpec + + +SAMPLE_RATE = 44100 + + +class TestGenerateChirpSamples: + def test_length_matches_duration(self): + spec = ChirpSpec(from_hz=400, to_hz=2500, duration_ms=500, amplitude=0.8, envelope_ms=15) + samples = generate_chirp_samples(spec, sample_rate=SAMPLE_RATE) + assert len(samples) == int(SAMPLE_RATE * 0.5) + + def test_amplitude_bounds(self): + spec = ChirpSpec(from_hz=400, to_hz=2500, duration_ms=500, amplitude=0.8, envelope_ms=15) + samples = generate_chirp_samples(spec, sample_rate=SAMPLE_RATE) + peak = max(abs(s) for s in samples) + assert peak <= 0.8 + 1e-9 + # Non-trivial signal somewhere in the middle + assert peak > 0.5 + + def test_envelope_ramps_from_and_to_zero(self): + """Cosine envelope means the first and last samples are ~0.""" + spec = ChirpSpec(from_hz=400, to_hz=2500, duration_ms=500, amplitude=0.8, envelope_ms=15) + samples = generate_chirp_samples(spec, sample_rate=SAMPLE_RATE) + assert abs(samples[0]) < 0.01 + assert abs(samples[-1]) < 0.01 + + def test_linear_frequency_sweep_zero_crossings(self): + """For a 1000→3000 Hz linear sweep over 1 s the mean frequency is 2000 Hz, + which produces roughly 4000 zero crossings. Allow ±5 %. + """ + spec = ChirpSpec(from_hz=1000, to_hz=3000, duration_ms=1000, amplitude=1.0, envelope_ms=0) + samples = generate_chirp_samples(spec, sample_rate=SAMPLE_RATE) + zero_crossings = sum( + 1 for i in range(1, len(samples)) if samples[i - 1] * samples[i] < 0 + ) + assert 3800 < zero_crossings < 4200 + + def test_silent_when_amplitude_zero(self): + spec = ChirpSpec(400, 2500, 500, amplitude=0.0, envelope_ms=15) + samples = generate_chirp_samples(spec, sample_rate=SAMPLE_RATE) + assert all(s == 0.0 for s in samples) + + def test_empty_when_duration_zero(self): + spec = ChirpSpec(400, 2500, 0, amplitude=0.8, envelope_ms=0) + assert generate_chirp_samples(spec, sample_rate=SAMPLE_RATE) == [] + + +class TestWriteChirpWav: + def test_writes_valid_16bit_pcm_wav(self, tmp_path): + spec = ChirpSpec(400, 2500, 500, 0.8, 15) + out_path = tmp_path / "chirp.wav" + write_chirp_wav(spec, out_path, sample_rate=SAMPLE_RATE) + assert out_path.exists() + with wave.open(str(out_path), "rb") as w: + assert w.getnchannels() == 1 + assert w.getsampwidth() == 2 # 16-bit + assert w.getframerate() == SAMPLE_RATE + assert w.getnframes() == int(SAMPLE_RATE * 0.5) + + def test_samples_are_int16_and_nontrivial(self, tmp_path): + # amplitude = 1.0 would overflow int16 if not clipped + spec = ChirpSpec(400, 2500, 100, amplitude=1.0, envelope_ms=0) + out_path = tmp_path / "chirp.wav" + write_chirp_wav(spec, out_path, sample_rate=SAMPLE_RATE) + with wave.open(str(out_path), "rb") as w: + frames = w.readframes(w.getnframes()) + values = struct.unpack(f"<{len(frames) // 2}h", frames) + assert all(-32768 <= v <= 32767 for v in values) + assert max(abs(v) for v in values) > 20000 + + def test_returns_path(self, tmp_path): + out = tmp_path / "x.wav" + result = write_chirp_wav(ChirpSpec(400, 500, 10, 0.5, 0), out) + assert result == out + + +class TestSyncToneConfig: + def test_default_uses_egonaut_validated_defaults(self): + cfg = SyncToneConfig.default() + assert cfg.enabled is True + assert cfg.start_chirp.from_hz == 400 + assert cfg.start_chirp.to_hz == 2500 + assert cfg.start_chirp.duration_ms == 500 + assert cfg.start_chirp.amplitude == 0.8 + assert cfg.start_chirp.envelope_ms == 15 + # Stop chirp is the reverse sweep + assert cfg.stop_chirp.from_hz == 2500 + assert cfg.stop_chirp.to_hz == 400 + # Timing margins + assert cfg.post_start_stabilization_ms == 200 + assert cfg.pre_stop_tail_margin_ms == 200 + + def test_silent_factory_disables_playback(self): + cfg = SyncToneConfig.silent() + assert cfg.enabled is False + + def test_is_frozen(self): + cfg = SyncToneConfig.default() + with pytest.raises(dataclasses.FrozenInstanceError): + cfg.enabled = False # type: ignore[misc] + + def test_custom_values_round_trip(self): + cfg = SyncToneConfig( + enabled=True, + start_chirp=ChirpSpec(100, 500, 100, 0.5, 5), + stop_chirp=ChirpSpec(500, 100, 100, 0.5, 5), + post_start_stabilization_ms=50, + pre_stop_tail_margin_ms=50, + ) + assert cfg.start_chirp.from_hz == 100 + assert cfg.post_start_stabilization_ms == 50 + + +class TestSilentChirpPlayer: + def test_play_is_noop(self): + player: ChirpPlayer = SilentChirpPlayer() + player.play(ChirpSpec(400, 2500, 100, 0.5, 5)) # must not raise + + def test_is_silent_returns_true(self): + assert SilentChirpPlayer().is_silent() is True + + def test_satisfies_chirp_player_protocol(self): + assert isinstance(SilentChirpPlayer(), ChirpPlayer) + + +class TestSoundDeviceChirpPlayer: + def test_play_forwards_samples_and_sample_rate_to_sounddevice(self): + fake_sd = MagicMock() + with patch.dict(sys.modules, {"sounddevice": fake_sd}): + player = SoundDeviceChirpPlayer(sample_rate=SAMPLE_RATE) + player.play(ChirpSpec(400, 2500, 100, 0.5, 5)) + assert fake_sd.play.called + args, kwargs = fake_sd.play.call_args + samples = args[0] + assert len(samples) == int(SAMPLE_RATE * 0.1) + sent_rate = args[1] if len(args) > 1 else kwargs.get("samplerate") + assert sent_rate == SAMPLE_RATE + + def test_play_is_non_blocking(self): + """play() must NEVER call sd.wait() — the orchestrator owns all timing.""" + fake_sd = MagicMock() + with patch.dict(sys.modules, {"sounddevice": fake_sd}): + player = SoundDeviceChirpPlayer(sample_rate=SAMPLE_RATE) + player.play(ChirpSpec(400, 2500, 500, 0.8, 15)) + assert not fake_sd.wait.called + + def test_is_silent_returns_false(self): + fake_sd = MagicMock() + with patch.dict(sys.modules, {"sounddevice": fake_sd}): + assert SoundDeviceChirpPlayer().is_silent() is False + + +class TestCreateDefaultPlayer: + def test_returns_sounddevice_backend_when_import_succeeds(self): + fake_sd = MagicMock() + with patch.dict(sys.modules, {"sounddevice": fake_sd}): + assert isinstance(create_default_player(), SoundDeviceChirpPlayer) + + def test_returns_silent_backend_when_import_fails(self): + # Patch sounddevice to None — import raises ImportError + original = sys.modules.pop("sounddevice", None) + try: + with patch.dict(sys.modules, {"sounddevice": None}): + assert isinstance(create_default_player(), SilentChirpPlayer) + finally: + if original is not None: + sys.modules["sounddevice"] = original diff --git a/tests/test_types.py b/tests/unit/test_types.py similarity index 50% rename from tests/test_types.py rename to tests/unit/test_types.py index 93d4648..7fe4e89 100644 --- a/tests/test_types.py +++ b/tests/unit/test_types.py @@ -132,3 +132,157 @@ def test_sensor_sample_nested_round_trip(): assert restored.channels == channels assert restored.channels["joints"]["wrist"] == [0.1, 0.2, 0.3] assert restored.channels["gestures"]["pinch"] == 0.95 + + +import pytest +from dataclasses import FrozenInstanceError +from pathlib import Path + +from syncfield.types import ( + ChirpSpec, + FinalizationReport, + HealthEvent, + HealthEventKind, + SampleEvent, + SessionReport, + SessionState, + StreamCapabilities, +) + + +class TestStreamCapabilities: + def test_is_frozen(self): + caps = StreamCapabilities( + provides_audio_track=True, + supports_precise_timestamps=True, + is_removable=False, + produces_file=True, + ) + with pytest.raises(FrozenInstanceError): + caps.provides_audio_track = False # type: ignore[misc] + + def test_default_all_false(self): + caps = StreamCapabilities() + assert caps.provides_audio_track is False + assert caps.supports_precise_timestamps is False + assert caps.is_removable is False + assert caps.produces_file is False + + def test_to_dict_round_trip(self): + caps = StreamCapabilities( + provides_audio_track=True, + supports_precise_timestamps=False, + is_removable=True, + produces_file=True, + ) + d = caps.to_dict() + assert d == { + "provides_audio_track": True, + "supports_precise_timestamps": False, + "is_removable": True, + "produces_file": True, + } + + +class TestSessionState: + def test_states(self): + assert SessionState.IDLE.value == "idle" + assert SessionState.PREPARING.value == "preparing" + assert SessionState.RECORDING.value == "recording" + assert SessionState.STOPPING.value == "stopping" + assert SessionState.STOPPED.value == "stopped" + + +class TestHealthEvent: + def test_fields(self): + ev = HealthEvent( + stream_id="cam_left", + kind=HealthEventKind.HEARTBEAT, + at_ns=123_456_789, + detail=None, + ) + assert ev.stream_id == "cam_left" + assert ev.kind is HealthEventKind.HEARTBEAT + assert ev.at_ns == 123_456_789 + assert ev.detail is None + + def test_to_dict(self): + ev = HealthEvent( + stream_id="imu", + kind=HealthEventKind.DROP, + at_ns=42, + detail="buffer overflow", + ) + assert ev.to_dict() == { + "stream_id": "imu", + "kind": "drop", + "at_ns": 42, + "detail": "buffer overflow", + } + + +class TestSampleEvent: + def test_minimal(self): + ev = SampleEvent(stream_id="cam", frame_number=7, capture_ns=1000) + assert ev.stream_id == "cam" + assert ev.frame_number == 7 + assert ev.capture_ns == 1000 + + +class TestFinalizationReport: + def test_completed(self): + report = FinalizationReport( + stream_id="cam_left", + status="completed", + frame_count=120, + file_path=Path("/tmp/cam_left.mp4"), + first_sample_at_ns=1000, + last_sample_at_ns=5000, + health_events=[], + error=None, + ) + assert report.status == "completed" + assert report.error is None + + def test_failed_has_error(self): + report = FinalizationReport( + stream_id="broken", + status="failed", + frame_count=0, + file_path=None, + first_sample_at_ns=None, + last_sample_at_ns=None, + health_events=[], + error="device disconnected", + ) + assert report.status == "failed" + assert report.error == "device disconnected" + + +class TestChirpSpec: + def test_is_frozen(self): + spec = ChirpSpec(from_hz=400, to_hz=2500, duration_ms=500, amplitude=0.8, envelope_ms=15) + with pytest.raises(FrozenInstanceError): + spec.from_hz = 100 # type: ignore[misc] + + def test_to_dict(self): + spec = ChirpSpec(400, 2500, 500, 0.8, 15) + assert spec.to_dict() == { + "from_hz": 400, + "to_hz": 2500, + "duration_ms": 500, + "amplitude": 0.8, + "envelope_ms": 15, + } + + +class TestSessionReport: + def test_minimal(self): + report = SessionReport( + host_id="rig_01", + finalizations=[], + chirp_start_ns=None, + chirp_stop_ns=None, + ) + assert report.host_id == "rig_01" + assert report.finalizations == [] diff --git a/tests/unit/test_writer.py b/tests/unit/test_writer.py new file mode 100644 index 0000000..e9d319a --- /dev/null +++ b/tests/unit/test_writer.py @@ -0,0 +1,239 @@ +"""Tests for syncfield.writer.""" + +import json +from pathlib import Path + +from syncfield.types import ( + ChirpSpec, + FrameTimestamp, + HealthEvent, + HealthEventKind, + SensorSample, + StreamCapabilities, + SyncPoint, +) +from syncfield.writer import ( + SensorWriter, + SessionLogWriter, + StreamWriter, + write_manifest, + write_sync_point, +) + + +def test_stream_writer_creates_jsonl(tmp_path: Path): + w = StreamWriter("cam_left", tmp_path) + w.open() + for i in range(3): + w.write(FrameTimestamp(frame_number=i, capture_ns=1000 + i, clock_domain="h1")) + w.close() + + path = tmp_path / "cam_left.timestamps.jsonl" + assert path.exists() + + lines = path.read_text().strip().split("\n") + assert len(lines) == 3 + + first = json.loads(lines[0]) + assert first["frame_number"] == 0 + assert first["capture_ns"] == 1000 + assert first["clock_source"] == "host_monotonic" + assert first["clock_domain"] == "h1" + + +def test_stream_writer_count(tmp_path: Path): + w = StreamWriter("imu", tmp_path) + w.open() + assert w.count == 0 + w.write(FrameTimestamp(frame_number=0, capture_ns=100)) + w.write(FrameTimestamp(frame_number=1, capture_ns=200)) + assert w.count == 2 + w.close() + + +def test_write_sync_point(tmp_path: Path): + sp = SyncPoint( + monotonic_ns=111, + wall_clock_ns=222, + host_id="test", + timestamp_ms=333, + iso_datetime="2024-01-01T00:00:00", + ) + path = write_sync_point(sp, tmp_path) + assert path == tmp_path / "sync_point.json" + + data = json.loads(path.read_text()) + assert data["sdk_version"] == "0.2.0" + assert data["host_id"] == "test" + assert data["monotonic_ns"] == 111 + assert data["wall_clock_ns"] == 222 + + +def test_stream_writer_raises_if_not_open(tmp_path: Path): + w = StreamWriter("x", tmp_path) + try: + w.write(FrameTimestamp(frame_number=0, capture_ns=1)) + assert False, "should have raised" + except RuntimeError: + pass + + +# --- SensorWriter tests --- + + +def test_sensor_writer_creates_jsonl(tmp_path: Path): + w = SensorWriter("imu", tmp_path) + w.open() + for i in range(3): + w.write(SensorSample( + frame_number=i, + capture_ns=1000 + i, + channels={"accel_x": float(i), "accel_y": float(i * 2)}, + clock_domain="h1", + )) + w.close() + + path = tmp_path / "imu.jsonl" + assert path.exists() + + lines = path.read_text().strip().split("\n") + assert len(lines) == 3 + + first = json.loads(lines[0]) + assert first["frame_number"] == 0 + assert first["capture_ns"] == 1000 + assert first["channels"] == {"accel_x": 0.0, "accel_y": 0.0} + assert first["clock_domain"] == "h1" + + +def test_sensor_writer_count(tmp_path: Path): + w = SensorWriter("sensor", tmp_path) + w.open() + assert w.count == 0 + w.write(SensorSample(frame_number=0, capture_ns=100, channels={"v": 1.0})) + w.write(SensorSample(frame_number=1, capture_ns=200, channels={"v": 2.0})) + assert w.count == 2 + w.close() + + +def test_sensor_writer_raises_if_not_open(tmp_path: Path): + w = SensorWriter("x", tmp_path) + try: + w.write(SensorSample(frame_number=0, capture_ns=1, channels={"v": 0.0})) + assert False, "should have raised" + except RuntimeError: + pass + + +def test_write_manifest(tmp_path: Path): + streams = { + "cam_left": {"type": "video", "timestamps_path": "cam_left.timestamps.jsonl"}, + } + path = write_manifest("test_host", streams, tmp_path) + assert path == tmp_path / "manifest.json" + + data = json.loads(path.read_text()) + assert data["sdk_version"] == "0.2.0" + assert data["host_id"] == "test_host" + assert "streams" in data + assert data["streams"]["cam_left"]["type"] == "video" + + +# --- sync_point.json chirp extensions --- + + +class TestSyncPointWithChirp: + def test_writes_chirp_fields_when_provided(self, tmp_path: Path): + sp = SyncPoint.create_now("h") + spec = ChirpSpec(400, 2500, 500, 0.8, 15) + path = write_sync_point( + sp, + tmp_path, + chirp_start_ns=1_000_000_000, + chirp_stop_ns=5_000_000_000, + chirp_spec=spec, + ) + data = json.loads(path.read_text()) + assert data["chirp_start_ns"] == 1_000_000_000 + assert data["chirp_stop_ns"] == 5_000_000_000 + assert data["chirp_spec"] == spec.to_dict() + + def test_omits_chirp_fields_when_none(self, tmp_path: Path): + sp = SyncPoint.create_now("h") + path = write_sync_point(sp, tmp_path) + data = json.loads(path.read_text()) + assert "chirp_start_ns" not in data + assert "chirp_stop_ns" not in data + assert "chirp_spec" not in data + + +# --- manifest.json capability round-trip --- + + +class TestManifestWithCapabilities: + def test_writes_capabilities_when_provided(self, tmp_path: Path): + caps = StreamCapabilities(provides_audio_track=True, produces_file=True) + streams = { + "cam": { + "type": "video", + "path": "cam.mp4", + "capabilities": caps.to_dict(), + } + } + path = write_manifest("h", streams, tmp_path) + data = json.loads(path.read_text()) + assert data["streams"]["cam"]["capabilities"]["provides_audio_track"] is True + assert data["streams"]["cam"]["capabilities"]["produces_file"] is True + + +# --- SessionLogWriter --- + + +class TestSessionLogWriter: + def test_writes_events_and_health_as_jsonl(self, tmp_path: Path): + writer = SessionLogWriter(tmp_path) + writer.open() + writer.log_event( + { + "kind": "state_transition", + "from": "idle", + "to": "preparing", + "at_ns": 100, + } + ) + writer.log_health( + HealthEvent("cam", HealthEventKind.HEARTBEAT, at_ns=200, detail=None) + ) + writer.close() + + lines = (tmp_path / "session_log.jsonl").read_text().strip().split("\n") + assert len(lines) == 2 + ev1 = json.loads(lines[0]) + ev2 = json.loads(lines[1]) + assert ev1["kind"] == "state_transition" + assert ev1["from"] == "idle" + assert ev2["kind"] == "health" + assert ev2["stream_id"] == "cam" + assert ev2["health_kind"] == "heartbeat" + + def test_flushes_on_every_write(self, tmp_path: Path): + """Log lines must survive a process crash mid-recording.""" + writer = SessionLogWriter(tmp_path) + writer.open() + writer.log_event({"kind": "test", "at_ns": 1}) + # Without calling close(), the line must already be on disk + content = (tmp_path / "session_log.jsonl").read_text() + assert "test" in content + writer.close() + + def test_log_event_before_open_raises(self, tmp_path: Path): + writer = SessionLogWriter(tmp_path) + try: + writer.log_event({"kind": "x", "at_ns": 1}) + assert False, "should have raised" + except RuntimeError: + pass + + def test_path_property_points_at_session_log_jsonl(self, tmp_path: Path): + writer = SessionLogWriter(tmp_path) + assert writer.path == tmp_path / "session_log.jsonl" diff --git a/uv.lock b/uv.lock index aee0239..ee625cd 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,173 @@ version = 1 revision = 3 requires-python = ">=3.9" resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", "python_full_version < '3.10'", ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +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 = "bleak" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.10'" }, + { name = "dbus-fast", version = "2.45.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, + { name = "pyobjc-core", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "winrt-runtime", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/88/6bb2bcb94ef7a2f37c5bd5ec99a4ae9208c4caa3fa6d203f9b601e047e64/bleak-1.1.1.tar.gz", hash = "sha256:eeef18053eb3bd569a25bff62cd4eb9ee56be4d84f5321023a7c4920943e6ccb", size = 116277, upload-time = "2025-09-07T18:44:48.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5a/c3378ba7b05abc7c2d95ae492eac0523d937c77afcb9ff7e7f67fe2ca11d/bleak-1.1.1-py3-none-any.whl", hash = "sha256:e601371396e357d95ee3c256db65b7da624c94ef6f051d47dfce93ea8361c22e", size = 136534, upload-time = "2025-09-07T18:44:47.525Z" }, +] + +[[package]] +name = "bleak" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "async-timeout", marker = "python_full_version == '3.10.*'" }, + { name = "dbus-fast", version = "4.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, + { name = "pyobjc-core", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "winrt-runtime", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/9f/dd19d92309e4a37823210827f0f42460e69603254309b99499622b511294/bleak-3.0.1.tar.gz", hash = "sha256:c8ff077519f8c30a972fd0d22f47a54b981184b2f2a0886d02e55acadbc1045d", size = 124162, upload-time = "2026-03-25T15:43:01.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/9c/839e4ff0393070396c656fa6616d0d2512f60b571c1263183e709db1c365/bleak-3.0.1-py3-none-any.whl", hash = "sha256:49f93f24ce96610529842da2d9856e7f46597e25966c0f1cfc737f0191566de6", size = 144735, upload-time = "2026-03-25T15:43:00.285Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, + { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, + { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -15,12 +178,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "dbus-fast" +version = "2.45.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/45/43e2826069e8ed2cb3a3b83da72d39a0fe52ece2eca3cac8ff5e070bbfa4/dbus_fast-2.45.1.tar.gz", hash = "sha256:486195c42c5f8fac77e9c55b575e2c85636cff7db45ebc7a19f680b3b4084314", size = 72517, upload-time = "2025-11-08T22:13:25.28Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/8f/9712a58c4ceec5b20e25bed98f23d84d704f804149fd094df62d177a2958/dbus_fast-2.45.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727348f0131e8c130ba877a62e6d5a5c4e416032c24d2808dc83304fb15bd3c4", size = 803275, upload-time = "2025-11-08T22:21:45.701Z" }, + { url = "https://files.pythonhosted.org/packages/ba/32/1f453f8dc100208f4d6b2b1821662fe52be38d45ad8b5e85dcd83a971849/dbus_fast-2.45.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b45d713d2176b37db9fed3754ec59b816ba31f07560f10e516b0c1b9b6e2cc9e", size = 846740, upload-time = "2025-11-08T22:21:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/d3/5f/b583e4ec8cd02b90ab7baf7dbd84462bd5669954729c8a3d94db1bfa3f95/dbus_fast-2.45.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4d1accbcc2c61a18c2fde92e5935f3d84fb7171c1729320cdc5a8cec3747ce9", size = 808366, upload-time = "2025-11-08T22:21:48.882Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4a/de8bb2acb27fb50fe97b16cef432d28634fc36cded1cf83b3ee86e32165b/dbus_fast-2.45.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:350e8d730e81ffff869f4cc70a06d5b7b45b46e0cca7bb997a6f47bcfc7239bc", size = 853436, upload-time = "2025-11-08T22:21:50.545Z" }, + { url = "https://files.pythonhosted.org/packages/02/7b/5c1809fdbfdcd8275409645d22646688142e51b548c3ad2089919eb59679/dbus_fast-2.45.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a869a4a1f7f6d5bc790b2cc806cc63fb46a72f695bab42c34b791e1cc384ae", size = 800061, upload-time = "2025-11-08T22:21:54.707Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/4498a7c729a7245094fe11373ed021878998e6c6155139a835f45567924a/dbus_fast-2.45.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21821dba1bcf1a60f2045ae7913b59598451b60289a0a587ab2ae2e3b9ed62d8", size = 844863, upload-time = "2025-11-08T22:21:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/07/76/b39d0085bc18df0b370d780d206d380e53a924e8ee52152f8dde4a0d5146/dbus_fast-2.45.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b521209f0c87e8277c4b56a18b38d3fbadcffb85853d6e84cf4472f42f1c2797", size = 805252, upload-time = "2025-11-08T22:21:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/f7/76/b4762c78d9a7f5f741d8162f2cef13f6878afbba3e3e19d070c4cf168137/dbus_fast-2.45.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97f1fb7efee8dd8b1374f31309046e978c41e5044609c0f48d8080a76487d45e", size = 851143, upload-time = "2025-11-08T22:22:00.766Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/afcbbaffb67a2e3082a662fb5af6a8ff271fbbbccf2c80610f1c72f8ea7b/dbus_fast-2.45.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5552dbeac1ff70087d640d546dbdcf30df80edc72b5d85f6925efee756f42a9d", size = 768663, upload-time = "2025-11-08T22:22:03.987Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a2/0ada312998d0908d9a2e3d7fe4644d87ac85d8c72a02e298380813f2b2f3/dbus_fast-2.45.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9877d2bfea6636969ac44dd64da7f4f5409f3f999de99c817673653c885f291d", size = 817021, upload-time = "2025-11-08T22:22:05.852Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b2/4567269a622c5f017d6366d030f74f22ece6d557df36d51fabb19fbf54b5/dbus_fast-2.45.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a3bbe2c2aa70238dcbdf835d1f5f802a5360db05420e564e71d4f008aa2d5f1", size = 774897, upload-time = "2025-11-08T22:22:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/9a/72/ae3c649fbec166d372d330790dcd912e4fdfa698ff985c9473b2349c57dd/dbus_fast-2.45.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14eaf249d8b2a2d12c9b6c177f5d1ccce3fe9c6b006793c997d0f49200ed6f83", size = 824669, upload-time = "2025-11-08T22:22:09.643Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4a/661af12c94dfa7b53c39d84e70224ef9255f9b7305d3ca545b9abfde2cc6/dbus_fast-2.45.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9251663f9691c6618d8cd889fbd568ef874630f7db9607cc041ce7d6988e5c5e", size = 766081, upload-time = "2025-11-08T22:22:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/f2f549ef6eae446a89d0d22114b2497b2cc6890cabd7529c168a92020b97/dbus_fast-2.45.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16986f33c5f3af387e23a199690b90a5df61958218af5c396c6a95cc9e21ab92", size = 815904, upload-time = "2025-11-08T22:22:15.299Z" }, + { url = "https://files.pythonhosted.org/packages/fe/85/978e1805e4a75d666d962844a2c69055ddf1fe9fd58a76d0be11ca85f833/dbus_fast-2.45.1-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:8c171a9ff6c3b4e3f5a18663409c5f3c4e554536cc34a8e5bcaf183ace6add36", size = 787810, upload-time = "2025-11-08T22:13:23.549Z" }, + { url = "https://files.pythonhosted.org/packages/00/79/39ae941205fbd01d167789da6792d3d5fbf4049fa567377297b2e4a5f10c/dbus_fast-2.45.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:415afc76202b55bbf8942018d8293e1ea8eb2d64262384f87b0b2b6e73d94da4", size = 772393, upload-time = "2025-11-08T22:22:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/6b/db/096b362c425e283c40e1cdae199896b6b67d4438ffbd4e94038c8e561778/dbus_fast-2.45.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efbf5b70aefd26c6362fe33d6f783752c913b78b8855dba862961aa36b1467bf", size = 824230, upload-time = "2025-11-08T22:22:18.476Z" }, + { url = "https://files.pythonhosted.org/packages/6d/04/fb27a4b5cad99d6abaf1323536d121f3e0ed9a25ef4ac6ac9e2f149d098d/dbus_fast-2.45.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f945092cc7d8a0b8487938cc05ebb1ce6cb330129c2f0846329609de50ae214", size = 780343, upload-time = "2025-11-08T22:22:21.75Z" }, + { url = "https://files.pythonhosted.org/packages/b2/75/78725232d9de39aa73b65d3694edbb4e5f8017b300a0885b8ab52908e64e/dbus_fast-2.45.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b55083d4b3c583acd0744c984607be9e79fa3a32e069fb4a7a7d48027da347a", size = 821822, upload-time = "2025-11-08T22:22:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bc/171b44b62fd8fd6c62c39edd8525f26cfb3a753cb717bd96f4b04aadfb4d/dbus_fast-2.45.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:df58e68062f2019ee9ad87288a8c0de4fbbe6e93b7ac53e783010dc484fd0868", size = 786909, upload-time = "2025-11-08T22:22:25.54Z" }, + { url = "https://files.pythonhosted.org/packages/09/95/9ab9e53f5baf9f0b6364d3feefb974850794edfb929d91b294e9fec1623c/dbus_fast-2.45.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46b5d64a038a575878d2d59e4edd9a2771053b3de1d61bce9def516369da12af", size = 828742, upload-time = "2025-11-08T22:22:27.189Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b2/6439a0d56837a61292d6ace738f7a542fbe71184805194fa5e959396c13b/dbus_fast-2.45.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c61dba888158e955a95b6b4d29843ea38e17ec3441e4d4bc2958d689f5ee9db", size = 1482917, upload-time = "2025-11-08T22:22:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/7e/57/8e7e0d0285f7039573ed88bcfc0819e900afb40a5f70b561d7ed844a6a48/dbus_fast-2.45.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6b0dcefdeb20650274bd2d1351f4bf9c43105ebfb4de8dc7257e00ebbe784a1", size = 1557314, upload-time = "2025-11-08T22:22:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/b4/77/c0fdf922615964e45837bd41c4eccc78d9d83555a5d7e815401a6a3381f5/dbus_fast-2.45.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:237b5188c935061af752639db443000f671fde970ec68b3b30bebfbc86ba416d", size = 1496291, upload-time = "2025-11-08T22:22:35.121Z" }, + { url = "https://files.pythonhosted.org/packages/80/1d/f2780ac1e68965668ec4db4398436bd678d406b0fa2d4b859e5ffdf6d11e/dbus_fast-2.45.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:841715d398f21353c66314879ab30ddbcde6a66438e48463ad1d3cf7d164691b", size = 1573471, upload-time = "2025-11-08T22:22:36.841Z" }, + { url = "https://files.pythonhosted.org/packages/75/63/5e9a26112ed0fdd0802b734b0aa2ee8411667712e1e2e5ab033ac1926c05/dbus_fast-2.45.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dde7447f6363f66b1ae6e58936255d8bb86f96e68614c868a95ef63db8e97cc", size = 806597, upload-time = "2025-11-08T22:22:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/1a509e1171f51202e68f19ee35367716fba4834cd0b82871ac5c8687f6aa/dbus_fast-2.45.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1acf09d714378f3618f88122ce5da0982f4d23e4acf5c1b277a90e9b920a036", size = 850121, upload-time = "2025-11-08T22:22:42.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/9e/0d6f9e072137d9940fd5d8c43e1d6d42830185d77816b34d6d7a406128ac/dbus_fast-2.45.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a4b58b59d84dd98e4bf990680504b81cd9072e2f3444f7ba71db811225264436", size = 812134, upload-time = "2025-11-08T22:22:44.514Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ce/95eb79427a7df16c7abc2b6a8d951f40cd33e9af73f8e072ab2d13823f58/dbus_fast-2.45.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:17759804cca7f988ff71ebc60aa3054003d09c9ad9a5356a3e5a56cf6241aafc", size = 856661, upload-time = "2025-11-08T22:22:46.252Z" }, +] + +[[package]] +name = "dbus-fast" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/cd/402b0e524bdf37d8b1d22b1d926c538bf1d2eedf115ea1d401c6c08a7d81/dbus_fast-4.0.4.tar.gz", hash = "sha256:43137f0b73a7adbf7d5c0e9eb9d8d34df9e6e0aeafade2166e641c52dfe0a853", size = 75260, upload-time = "2026-04-02T04:41:16.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/3a/7a68781cb72d200dff3babf9b4515fb1aff39a18097fc19bc0a77516cd54/dbus_fast-4.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1dc97206c3f7fbf45e2fc12a2a3fdf0f080325a342a140edece4bf33f0bfd23", size = 831867, upload-time = "2026-04-02T04:49:45.245Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4a/6f2c968ac277251cdb79a0331abfb4d08f4e260c6685c57f8652ed28ef5a/dbus_fast-4.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:864e032aabfae051db27e1b63055a0f6460d0121d36ddcbe97b903149fa932f6", size = 876112, upload-time = "2026-04-02T04:49:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c2/d56d8769cb36ebb93b2b5fffbcd64b1b20c859987ed5e349f071d4cd678d/dbus_fast-4.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6738fa2a98652caf29e3368e6532f3faead007739f464d047c2cc46ec5b56f6c", size = 837186, upload-time = "2026-04-02T04:49:48.53Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/3ae1fd9dbb19cd402039476c3137f136b5961519e77fc653226985bce5cf/dbus_fast-4.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed82af97d3ceee7ba31a169b5b8b9341e05ebf45fdf93f02d2fee0b74ef56190", size = 882716, upload-time = "2026-04-02T04:49:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fc/e57373d034fbca2d51a4d2a3e13c64e008f2728ae3b650dc2c2f009f2c7e/dbus_fast-4.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf5109bbc98c8427f3cc22030de55e57a7288550bc0701a493905bbad3b3dc93", size = 829986, upload-time = "2026-04-02T04:49:53.356Z" }, + { url = "https://files.pythonhosted.org/packages/be/98/c23f8ef850d3c67c7d9e36d5c2f71a6654baad2a5079c5e7b2d7d085c692/dbus_fast-4.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:baca8ffed5a5b18cce9c1f17820cc699512bc97ac0743b8b88c61159aa0c961a", size = 875581, upload-time = "2026-04-02T04:49:55.112Z" }, + { url = "https://files.pythonhosted.org/packages/ad/65/3a605f3d6e7f97b9744795c02d496805057cd2b5414619870a68a21bfc57/dbus_fast-4.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cb570c6c8dac1ee2ca02621c0d410ec78d1a96535deee8a34e3028f475be7675", size = 835865, upload-time = "2026-04-02T04:49:56.624Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/05d503fe790a5aeae09ed32a85f25b0d4fa93a10befac3b603d6247a4e90/dbus_fast-4.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a2ee216e0ddd7b4db397bb31ff71741f6c7fc9618d9ca201e0096d9523b4f33", size = 881676, upload-time = "2026-04-02T04:49:58.36Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/86b4b7057d11af1549135234612e40401c69f339551a1c0cd011439a6b8b/dbus_fast-4.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a3db08cf1d98bbedfb73467b774fd49adcf83935b8a92f6c552ce78d93491f", size = 792151, upload-time = "2026-04-02T04:50:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/95/e5/056a5845b19202336caed7d3b18896c75ec059b68c16f4ded71749c9e0a2/dbus_fast-4.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74183b4ef4cc79a9cd1a46e006c19d00ce9abc7d99d36e6adcd094f414446d38", size = 844081, upload-time = "2026-04-02T04:50:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/25/03/aa1ef750da5f4cfd15eac9892c05ff95b6380cdc44d5bb63b8379c63e81f/dbus_fast-4.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:003f042b1299ebac900d6ee30e8ab2a29988937f56792894da7b5d39a26b1f5f", size = 799573, upload-time = "2026-04-02T04:50:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/2338ca2a51eca2392eddd1c1854a5127a8deb705a65a1fc66dc49d1a5557/dbus_fast-4.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87be8a41330481c3d3a5e30af10ec713e4f08fe0d6c36f5a401eaef7b5526417", size = 852067, upload-time = "2026-04-02T04:50:07.348Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3e/d8b733cca2dc03746496c2ea799125414cd875830a8cb384f1395ab6993d/dbus_fast-4.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fe60ce3a97e265a3ad117b7f40fc8c08357781b1a2ed3e853f29f9e35d24af7", size = 790985, upload-time = "2026-04-02T04:50:10.742Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/ec643d891f4bb38347172ca99e4fbeaa55b9a5c3a744bac42c3074893482/dbus_fast-4.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982235dabb7e7187df4c2c299d95a6232aebc95c8c906496f630265a10bfdf95", size = 840552, upload-time = "2026-04-02T04:50:12.43Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/50fb58f11387ab165f4ad010db9f5434aa386b8308467b5d968a10d09864/dbus_fast-4.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33c0168e0e65ed2d0fa91682773cc893472e3d71a5e085ee082e6484c2d29f4a", size = 797179, upload-time = "2026-04-02T04:50:14.435Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/dd83b280a4a405f7e1558951492714935e0c1601724d35a0db970afd0ad0/dbus_fast-4.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:069df5d46390299d7fccfc0e704af504a8b75794c2c3bc0246b6db0db1f245b0", size = 849052, upload-time = "2026-04-02T04:50:16.563Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/22c8904c1f3ecaee2012dfb1eb05d0481bedfaea80f27eab66d120f80b37/dbus_fast-4.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3a8c07d672b1656a9c7cbdc9a85f493602f72fce8e7f9d97c5e972fa22a684", size = 804433, upload-time = "2026-04-02T04:50:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e9/4b3eabb25886f33218b96c7a0e2cd684a50932e00d951aae0181075d5217/dbus_fast-4.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5512b9901ba600f1efdae2555ea18da2b67a46e5c24e397edb38ecbbde4aa43f", size = 846351, upload-time = "2026-04-02T04:50:22.9Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bc/a8330670389de2b69e7fb43b20ad08f0c3614f7d4a3a6cc9cbfcf72555fb/dbus_fast-4.0.4-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:36f86ba826f05c4d238c9198ae962f90a9cd27ffacd3a4a988166b3d06c699ca", size = 844513, upload-time = "2026-04-02T04:41:15.087Z" }, + { url = "https://files.pythonhosted.org/packages/ea/54/11a6f5cd13e67d30056651a6324b620f621a6980c1fc872fed3d1acdd1a0/dbus_fast-4.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:df0f88a9c1c9f9365627fbfb3dcae3589a12fdc82594945832cb24d9a62674d1", size = 811253, upload-time = "2026-04-02T04:50:24.615Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bd/30c204bdd0a779cf46f3a4faad5cb62c9bd42a22ebcb89caffecf488493d/dbus_fast-4.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c6c8722e829b8e750fd6db2906b298d3bcfc32d8cdf766e50ba474ef48bf2af", size = 853231, upload-time = "2026-04-02T04:50:26.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/73/af9794109638e38d59ea1ecbf18c4dea25f802debffc285504a6ebc81b2c/dbus_fast-4.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad5240c29ec631f9610e03a6bbf4f4c22274e04ea8791e8401542cd6cdbd7b28", size = 1534429, upload-time = "2026-04-02T04:50:29.998Z" }, + { url = "https://files.pythonhosted.org/packages/82/3d/a033cc655a3a32ee145f575cd05066cf5bed71e24238947d77fb9597c905/dbus_fast-4.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5843c3e543ac1d48cafff67394ada9e568f20d122b44408e378ae43402b785ca", size = 1610602, upload-time = "2026-04-02T04:50:32.146Z" }, + { url = "https://files.pythonhosted.org/packages/4a/46/2834b825da3f6ef206f5bc55363bc8751addcfc03fa34c639f72006f6a3e/dbus_fast-4.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7ebb845f2e15ef19f4a99cb879dfbcb1c7028a97d5aaba93b36f65cfd938a022", size = 1550136, upload-time = "2026-04-02T04:50:34.311Z" }, + { url = "https://files.pythonhosted.org/packages/9e/01/894b2f6954d12c5c527232906d67272b59f0f12f386cd2d394d5e5eac7fc/dbus_fast-4.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:698fe83128150dd49aaa7deb5493523309f0a9749c3c075730a6981851607455", size = 1627660, upload-time = "2026-04-02T04:50:36.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b8/97db81ae9b874c135f29f743a0512ff4d2d0c541b893d66cb076746f323a/dbus_fast-4.0.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0827134ae05d0f23349c0505ecc3b54ae8359be7f634b9eb16c8ad2dff9d51bb", size = 836763, upload-time = "2026-04-02T04:50:39.786Z" }, + { url = "https://files.pythonhosted.org/packages/32/63/237400ece09ba18d93123c803ae6ab8b7581c93d65ac6930ca9e17c73e33/dbus_fast-4.0.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44e605de4e18d0e94690d0ef7270e5f1c396ec30e0aac0ae3cac8886b458965c", size = 880152, upload-time = "2026-04-02T04:50:41.558Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b7/1d959d82d16c92a6022913fcac41aaeae60b3974760e660f188f86ec04dd/dbus_fast-4.0.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0c869d1949065d7b3c75d926cd461faa434691b4cf57a10559617821afe30737", size = 842338, upload-time = "2026-04-02T04:50:43.58Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/1f818f5dad75b806e1e65586e5380bec64565caf7caeee7047dfd5ff8c3d/dbus_fast-4.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:92b3aaea0e6df4cf83208ae994b08554335166eff726947733b93da748eab641", size = 886837, upload-time = "2026-04-02T04:50:45.644Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -44,13 +288,236 @@ name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } 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 = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -69,6 +536,31 @@ 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 = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -78,6 +570,180 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyobjc-core" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/e9/0b85c81e2b441267bca707b5d89f56c2f02578ef8f3eafddf0e0c0b8848c/pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe", size = 974602, upload-time = "2025-06-14T20:56:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/c5/9fa74ef6b83924e657c5098d37b36b66d1e16d13bc45c44248c6248e7117/pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c", size = 676323, upload-time = "2025-06-14T20:44:44.675Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a7/55afc166d89e3fcd87966f48f8bca3305a3a2d7c62100715b9ffa7153a90/pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33", size = 671075, upload-time = "2025-06-14T20:44:46.594Z" }, + { url = "https://files.pythonhosted.org/packages/c0/09/e83228e878e73bf756749939f906a872da54488f18d75658afa7f1abbab1/pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529", size = 677985, upload-time = "2025-06-14T20:44:48.375Z" }, + { url = "https://files.pythonhosted.org/packages/c5/24/12e4e2dae5f85fd0c0b696404ed3374ea6ca398e7db886d4f1322eb30799/pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c", size = 676431, upload-time = "2025-06-14T20:44:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/f7/79/031492497624de4c728f1857181b06ce8c56444db4d49418fa459cba217c/pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2", size = 719330, upload-time = "2025-06-14T20:44:51.621Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7d/6169f16a0c7ec15b9381f8bf33872baf912de2ef68d96c798ca4c6ee641f/pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731", size = 667203, upload-time = "2025-06-14T20:44:53.262Z" }, + { url = "https://files.pythonhosted.org/packages/49/0f/f5ab2b0e57430a3bec9a62b6153c0e79c05a30d77b564efdb9f9446eeac5/pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96", size = 708807, upload-time = "2025-06-14T20:44:54.851Z" }, + { url = "https://files.pythonhosted.org/packages/0b/3c/98f04333e4f958ee0c44ceccaf0342c2502d361608e00f29a5d50e16a569/pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157", size = 677089, upload-time = "2025-06-14T20:44:56.15Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748, upload-time = "2025-11-14T09:30:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "pyobjc-core", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/c5/7a866d24bc026f79239b74d05e2cf3088b03263da66d53d1b4cf5207f5ae/pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038", size = 5565335, upload-time = "2025-06-14T20:56:59.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/8f/67a7e166b615feb96385d886c6732dfb90afed565b8b1f34673683d73cd9/pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9", size = 388187, upload-time = "2025-06-14T20:46:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/90/43/6841046aa4e257b6276cd23e53cacedfb842ecaf3386bb360fa9cc319aa1/pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0", size = 388177, upload-time = "2025-06-14T20:46:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/da/41c0f7edc92ead461cced7e67813e27fa17da3c5da428afdb4086c69d7ba/pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0", size = 388983, upload-time = "2025-06-14T20:46:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0b/a01477cde2a040f97e226f3e15e5ffd1268fcb6d1d664885a95ba592eca9/pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da", size = 389049, upload-time = "2025-06-14T20:46:53.757Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/64cf2661f6ab7c124d0486ec6d1d01a9bb2838a0d2a46006457d8c5e6845/pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350", size = 393110, upload-time = "2025-06-14T20:46:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/87/01e35c5a3c5bbdc93d5925366421e10835fcd7b23347b6c267df1b16d0b3/pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0", size = 392644, upload-time = "2025-06-14T20:46:56.503Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/54afe9ffee547c41e1161691e72067a37ed27466ac71c089bfdcd07ca70d/pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71", size = 396742, upload-time = "2025-06-14T20:46:57.64Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9b/5499d1ed6790b037b12831d7038eb21031ab90a033d4cfa43c9b51085925/pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721", size = 388163, upload-time = "2025-06-14T20:46:58.72Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "pyobjc-core", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825, upload-time = "2025-11-14T09:40:28.354Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "pyobjc-core", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyobjc-framework-cocoa", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fe/2081dfd9413b7b4d719935c33762fbed9cce9dc06430f322d1e2c9dbcd91/pyobjc_framework_corebluetooth-11.1.tar.gz", hash = "sha256:1deba46e3fcaf5e1c314f4bbafb77d9fe49ec248c493ad00d8aff2df212d6190", size = 60337, upload-time = "2025-06-14T20:57:05.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/93/5b5ec131a238238ac1190758ccc5731b127e05e94a46abd08c5e1094cab9/pyobjc_framework_corebluetooth-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ab509994503a5f0ec0f446a7ccc9f9a672d5a427d40dba4563dd00e8e17dfb06", size = 13140, upload-time = "2025-06-14T20:47:27.457Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/3318e85b7328c99c752e40592a907fc5c755cddc6d73beacbb432f6aa2d0/pyobjc_framework_corebluetooth-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:433b8593eb1ea8b6262b243ec903e1de4434b768ce103ebe15aac249b890cc2a", size = 13143, upload-time = "2025-06-14T20:47:28.889Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bc/083ea1ae57a31645df7fad59921528f6690995f7b7c84a203399ded7e7fe/pyobjc_framework_corebluetooth-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:36bef95a822c68b72f505cf909913affd61a15b56eeaeafea7302d35a82f4f05", size = 13163, upload-time = "2025-06-14T20:47:29.624Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b5/d07cfa229e3fa0cd1cdaa385774c41907941d25b693cf55ad92e8584a3b3/pyobjc_framework_corebluetooth-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:992404b03033ecf637e9174caed70cb22fd1be2a98c16faa699217678e62a5c7", size = 13179, upload-time = "2025-06-14T20:47:30.376Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/476bca43002a6d009aed956d5ed3f3867c8d1dcd085dde8989be7020c495/pyobjc_framework_corebluetooth-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ebb8648f5e33d98446eb1d6c4654ba4fcc15d62bfcb47fa3bbd5596f6ecdb37c", size = 13358, upload-time = "2025-06-14T20:47:31.114Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/6c050dffb9acc49129da54718c545bc5062f61a389ebaa4727bc3ef0b5a9/pyobjc_framework_corebluetooth-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e84cbf52006a93d937b90421ada0bc4a146d6d348eb40ae10d5bd2256cc92206", size = 13245, upload-time = "2025-06-14T20:47:31.939Z" }, + { url = "https://files.pythonhosted.org/packages/36/15/9068e8cb108e19e8e86cbf50026bb4c509d85a5d55e2d4c36e292be94337/pyobjc_framework_corebluetooth-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:4da1106265d7efd3f726bacdf13ba9528cc380fb534b5af38b22a397e6908291", size = 13439, upload-time = "2025-06-14T20:47:32.66Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4b/2d36b7efe08a6d9004f205ac7ad4348805a447a31a4feec6cd08af9d64fe/pyobjc_framework_corebluetooth-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9fa3781fea20a31b3bb809deaeeab3bdc7b86602a1fd829f0e86db11d7aa577", size = 13136, upload-time = "2025-06-14T20:47:33.381Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "pyobjc-core", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyobjc-framework-cocoa", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157, upload-time = "2025-11-14T10:13:28.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1b/06914f4eb1bd8ce598fdd210e1a7411556286910fc8d8919ab7dbaebe629/pyobjc_framework_corebluetooth-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:937849f4d40a33afbcc56cbe90c8d1fbf30fb27a962575b9fb7e8e2c61d3c551", size = 13187, upload-time = "2025-11-14T09:44:04.098Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/26ae106beb97e9c4745065edb3ce3c2bdd91d81f5b52b8224f82ce9d5fb9/pyobjc_framework_corebluetooth-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:37e6456c8a076bd5a2bdd781d0324edd5e7397ef9ac9234a97433b522efb13cf", size = 13189, upload-time = "2025-11-14T09:44:06.229Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/01fef62a479cdd6ff9ee40b6e062a205408ff386ce5ba56d7e14a71fcf73/pyobjc_framework_corebluetooth-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe72c9732ee6c5c793b9543f08c1f5bdd98cd95dfc9d96efd5708ec9d6eeb213", size = 13209, upload-time = "2025-11-14T09:44:08.203Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6c/831139ebf6a811aed36abfdfad846bc380dcdf4e6fb751a310ce719ddcfd/pyobjc_framework_corebluetooth-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a894f695e6c672f0260327103a31ad8b98f8d4fb9516a0383db79a82a7e58dc", size = 13229, upload-time = "2025-11-14T09:44:10.463Z" }, + { url = "https://files.pythonhosted.org/packages/09/3c/3a6fe259a9e0745aa4612dee86b61b4fd7041c44b62642814e146b654463/pyobjc_framework_corebluetooth-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1daf07a0047c3ed89fab84ad5f6769537306733b6a6e92e631581a0f419e3f32", size = 13409, upload-time = "2025-11-14T09:44:12.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/41/90640a4db62f0bf0611cf8a161129c798242116e2a6a44995668b017b106/pyobjc_framework_corebluetooth-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:15ba5207ca626dffe57ccb7c1beaf01f93930159564211cb97d744eaf0d812aa", size = 13222, upload-time = "2025-11-14T09:44:14.345Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/8ed2f0ca02b9abe204966142bd8c4501cf6da94234cc320c4c0562c467e8/pyobjc_framework_corebluetooth-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e5385195bd365a49ce70e2fb29953681eefbe68a7b15ecc2493981d2fb4a02b1", size = 13408, upload-time = "2025-11-14T09:44:16.558Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "pyobjc-core", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyobjc-framework-cocoa", version = "11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/89/7830c293ba71feb086cb1551455757f26a7e2abd12f360d375aae32a4d7d/pyobjc_framework_libdispatch-11.1.tar.gz", hash = "sha256:11a704e50a0b7dbfb01552b7d686473ffa63b5254100fdb271a1fe368dd08e87", size = 53942, upload-time = "2025-06-14T20:57:45.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/33/7a6b509e85d95ed5aa7c813c6bccfe4e0a1162baa02f51050d1da91408a9/pyobjc_framework_libdispatch-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c598c073a541b5956b5457b94bd33b9ce19ef8d867235439a0fad22d6beab49", size = 20444, upload-time = "2025-06-14T20:50:57.316Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cd/1010dee9f932a9686c27ce2e45e91d5b6875f5f18d2daafadea70090e111/pyobjc_framework_libdispatch-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ddca472c2cbc6bb192e05b8b501d528ce49333abe7ef0eef28df3133a8e18b7", size = 20441, upload-time = "2025-06-14T20:50:58.3Z" }, + { url = "https://files.pythonhosted.org/packages/ac/92/ff9ceb14e1604193dcdb50643f2578e1010c68556711cd1a00eb25489c2b/pyobjc_framework_libdispatch-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc9a7b8c2e8a63789b7cf69563bb7247bde15353208ef1353fff0af61b281684", size = 15627, upload-time = "2025-06-14T20:50:59.055Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/5851b68cd85b475ff1da08e908693819fd9a4ff07c079da9b0b6dbdaca9c/pyobjc_framework_libdispatch-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4e219849f5426745eb429f3aee58342a59f81e3144b37aa20e81dacc6177de1", size = 15648, upload-time = "2025-06-14T20:50:59.809Z" }, + { url = "https://files.pythonhosted.org/packages/1b/79/f905f22b976e222a50d49e85fbd7f32d97e8790dd80a55f3f0c305305c32/pyobjc_framework_libdispatch-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a9357736cb47b4a789f59f8fab9b0d10b0a9c84f9876367c398718d3de085888", size = 15912, upload-time = "2025-06-14T20:51:00.572Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/225a3645ba2711c3122eec3e857ea003646643b4122bd98db2a8831740ff/pyobjc_framework_libdispatch-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:cd08f32ea7724906ef504a0fd40a32e2a0be4d64b9239530a31767ca9ccfc921", size = 15655, upload-time = "2025-06-14T20:51:01.655Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b5/ff49fb81f13c7ec48cd7ccad66e1986ccc6aa1984e04f4a78074748f7926/pyobjc_framework_libdispatch-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:5d9985b0e050cae72bf2c6a1cc8180ff4fa3a812cd63b2dc59e09c6f7f6263a1", size = 15920, upload-time = "2025-06-14T20:51:02.407Z" }, + { url = "https://files.pythonhosted.org/packages/73/4c/4ef43d2ee85e55a73cfb5090cf29d2f1a5d82e6fe81623b62b7e008afe33/pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4", size = 20435, upload-time = "2025-06-14T20:51:03.137Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "pyobjc-core", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyobjc-framework-cocoa", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277, upload-time = "2025-11-14T10:16:46.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/76/9936d97586dbae4d7d10f3958d899ee7a763930af69b5ad03d4516178c7c/pyobjc_framework_libdispatch-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:50a81a29506f0e35b4dc313f97a9d469f7b668dae3ba597bb67bbab94de446bd", size = 20471, upload-time = "2025-11-14T09:52:53.134Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/c4aeab6ce7268373d4ceabbc5c406c4bbf557038649784384910932985f8/pyobjc_framework_libdispatch-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:954cc2d817b71383bd267cc5cd27d83536c5f879539122353ca59f1c945ac706", size = 20463, upload-time = "2025-11-14T09:52:55.703Z" }, + { url = "https://files.pythonhosted.org/packages/83/6f/96e15c7b2f7b51fc53252216cd0bed0c3541bc0f0aeb32756fefd31bed7d/pyobjc_framework_libdispatch-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0e9570d7a9a3136f54b0b834683bf3f206acd5df0e421c30f8fd4f8b9b556789", size = 15650, upload-time = "2025-11-14T09:52:59.284Z" }, + { url = "https://files.pythonhosted.org/packages/38/3a/d85a74606c89b6b293782adfb18711026ff79159db20fc543740f2ac0bc7/pyobjc_framework_libdispatch-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:58ffce5e6bcd7456b4311009480b195b9f22107b7682fb0835d4908af5a68ad0", size = 15668, upload-time = "2025-11-14T09:53:01.354Z" }, + { url = "https://files.pythonhosted.org/packages/cc/40/49b1c1702114ee972678597393320d7b33f477e9d24f2a62f93d77f23dfb/pyobjc_framework_libdispatch-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e9f49517e253716e40a0009412151f527005eec0b9a2311ac63ecac1bdf02332", size = 15938, upload-time = "2025-11-14T09:53:03.461Z" }, + { url = "https://files.pythonhosted.org/packages/59/d8/7d60a70fc1a546c6cb482fe0595cb4bd1368d75c48d49e76d0bc6c0a2d0f/pyobjc_framework_libdispatch-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0ebfd9e4446ab6528126bff25cfb09e4213ddf992b3208978911cfd3152e45f5", size = 15693, upload-time = "2025-11-14T09:53:05.531Z" }, + { url = "https://files.pythonhosted.org/packages/99/32/15e08a0c4bb536303e1568e2ba5cae1ce39a2e026a03aea46173af4c7a2d/pyobjc_framework_libdispatch-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:23fc9915cba328216b6a736c7a48438a16213f16dfb467f69506300b95938cc7", size = 15976, upload-time = "2025-11-14T09:53:07.936Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -104,7 +770,8 @@ name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, @@ -120,21 +787,89 @@ 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-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "sounddevice" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, +] + [[package]] name = "syncfield" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } +[package.optional-dependencies] +all = [ + { 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'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "opencv-python" }, + { name = "sounddevice" }, +] +audio = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sounddevice" }, +] +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'" }, +] +uvc = [ + { name = "opencv-python" }, +] + [package.dev-dependencies] 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-mock" }, ] [package.metadata] +requires-dist = [ + { name = "bleak", marker = "extra == 'all'", specifier = ">=0.21" }, + { name = "bleak", marker = "extra == 'ble'", specifier = ">=0.21" }, + { name = "numpy", marker = "extra == 'all'", specifier = ">=1.21" }, + { name = "numpy", marker = "extra == 'audio'", specifier = ">=1.21" }, + { name = "opencv-python", marker = "extra == 'all'", specifier = ">=4.5" }, + { name = "opencv-python", marker = "extra == 'uvc'", specifier = ">=4.5" }, + { name = "sounddevice", marker = "extra == 'all'", specifier = ">=0.4.6" }, + { name = "sounddevice", marker = "extra == 'audio'", specifier = ">=0.4.6" }, +] +provides-extras = ["audio", "uvc", "ble", "all"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.4.2" }] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-mock", specifier = ">=3.12.0" }, +] [[package]] name = "tomli" @@ -198,3 +933,264 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/28/26d86ca6d2f155f31ca61e069312034a8922a5a89f5d0fc68abb7c04aad1/winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3", size = 210993, upload-time = "2025-06-06T06:44:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/a4/f096687e0d1877d206bc5d1f5f07ff90e00b0772d69d4559ab2b6b37090b/winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257", size = 242210, upload-time = "2025-06-06T06:44:02.366Z" }, + { url = "https://files.pythonhosted.org/packages/ff/81/46927ce4d79fc8f40f193f35204bce79eff7c496d888825a7a74d8560b6e/winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae", size = 415833, upload-time = "2025-06-06T06:44:03.379Z" }, + { url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798, upload-time = "2025-06-06T06:44:04.775Z" }, + { url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032, upload-time = "2025-06-06T06:44:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659, upload-time = "2025-06-06T06:44:07.007Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/3dd06f2341fab6abb06588a16b30e0b213b0125be7b79dafc3bdba3b334a/winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1", size = 210090, upload-time = "2025-06-06T06:44:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a1/1d7248d5c62ccbea5f3e0da64ca4529ce99c639c3be2485b6ed709f5c740/winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576", size = 241391, upload-time = "2025-06-06T06:44:09.442Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ae/6a205d8dafc79f7c242be7f940b1e0c1971fd64ab3079bda4b514aa3d714/winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195", size = 415242, upload-time = "2025-06-06T06:44:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/31/12/f8a79bd0cdf1db78735619016b1b7f5efe8f138207a621edec9aae58f846/winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01", size = 211013, upload-time = "2025-06-06T06:44:14.651Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7d/1e7da43fd4dab3a7b181c5c5dde547f877dab391b7d34a11a835dd3ea616/winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa", size = 242293, upload-time = "2025-06-06T06:44:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/27/5e/dafd643a8ece50f3136dfb6d8d5bcbc10601f11bc09fdd87df5de8994889/winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d", size = 415952, upload-time = "2025-06-06T06:44:16.503Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/b7/822da8bc0b6a67cc0c3e460fef793f00c51a6fe59aa54f6bfe416519a9d9/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78", size = 105569, upload-time = "2025-06-06T07:00:05.406Z" }, + { url = "https://files.pythonhosted.org/packages/68/46/696893d3bae80751e35fb0fb8fae5e7fc94a5354dfb5e19167d415e27c66/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb", size = 114743, upload-time = "2025-06-06T07:00:06.408Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6a/a36b28739b73cc2c67050da866b063af135b5f6c071997c85a27adb6815c/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d", size = 105021, upload-time = "2025-06-06T07:00:07.299Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535, upload-time = "2025-06-06T07:00:08.146Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612, upload-time = "2025-06-06T07:00:08.984Z" }, + { url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017, upload-time = "2025-06-06T07:00:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/54/ff/c4a3de909a875b46fad5e9f4fd412bba48571405bfa802b878954abf128c/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c", size = 105752, upload-time = "2025-06-06T07:00:10.684Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/bfee1f0c8d188c561c5b946ab21f6a0037e60dea110e80b1d6a1d529639f/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca", size = 113356, upload-time = "2025-06-06T07:00:11.541Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/d9da9c29d36cabadef4e19c3e9ba6d2692f6a28224c81fcff757132ea0da/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148", size = 104724, upload-time = "2025-06-06T07:00:12.406Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/797516c5c0f8d7f5b680862e0ed7c1087c58aec0bcf57a417fa90f7eb983/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4", size = 105757, upload-time = "2025-06-06T07:00:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/05/6d/f60588846a065e69a2ec5e67c5f85eb45cb7edef2ee8974cd52fa8504de6/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3", size = 113363, upload-time = "2025-06-06T07:00:14.135Z" }, + { url = "https://files.pythonhosted.org/packages/2c/13/2d3c4762018b26a9f66879676ea15d7551cdbf339c8e8e0c56ea05ea31ef/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2", size = 104722, upload-time = "2025-06-06T07:00:14.999Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/91cfdf941a1ba791708ab3477fc4e46793c8fe9117fc3e0a8c5ac5d7a09c/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a", size = 109015, upload-time = "2025-09-20T07:09:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/7460655628d0f340a93524f5236bb9f8514eb0e1d334b38cba8a89f6c1a6/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c", size = 115931, upload-time = "2025-09-20T07:09:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/e1248dea2ab881eb76b61ff1ad6cb9c07ac005faf99349e4af0b29bc3f1b/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102", size = 109561, upload-time = "2025-09-20T07:09:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/91/b1/981062e842b69b2b21ca67d8067553432730f1ca2dcb54ec2a658ba1f7b2/winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6", size = 105771, upload-time = "2025-06-06T07:00:15.846Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6f/ca29e2d8c718c9561e04838cce337baaada36b4895fdff4738b92529b244/winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0", size = 114988, upload-time = "2025-06-06T07:00:16.734Z" }, + { url = "https://files.pythonhosted.org/packages/ad/06/a3bf54a487ab706bd5d0e05eeeab9256f46e0484edb2799fa7e37b3e0ba1/winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2", size = 105156, upload-time = "2025-06-06T07:00:17.613Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b9/c2b0d201b8b38895809591d089a5edc37e702a23f3a6bc6e542c5e7d6dbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866", size = 89730, upload-time = "2025-06-06T07:00:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/f086c3ac17745a71d8384e1831cab0d5a7c737e1fe5cb84d7584f6c14bbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1", size = 95825, upload-time = "2025-06-06T07:00:19.385Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/f7f830b2da1fb7ffcaf25ce2734db0019615111f8f39e7b4d83fea4a0bd0/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f", size = 89402, upload-time = "2025-06-06T07:00:20.178Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614, upload-time = "2025-06-06T07:00:20.952Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783, upload-time = "2025-06-06T07:00:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253, upload-time = "2025-06-06T07:00:22.603Z" }, + { url = "https://files.pythonhosted.org/packages/c9/15/ad05c28e049208c97011728e2debdb45439175f75efe357b6faa4c9ba099/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4", size = 90033, upload-time = "2025-06-06T07:00:23.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/48/074779081841f6eba4987930c4e7adcec38a5985b7dffd9fecc41f39a89c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89", size = 95824, upload-time = "2025-06-06T07:00:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/e01966033a02b2d0718710bb47ef4f6b9b5a619ca2c857e06eb5c8e3ed13/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff", size = 89311, upload-time = "2025-06-06T07:00:25.029Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/8fc8e57605ea08dd0723c035ed0c2d0435dace2bc80a66d33aecfea49a56/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90", size = 90037, upload-time = "2025-06-06T07:00:25.818Z" }, + { url = "https://files.pythonhosted.org/packages/86/83/503cf815d84c5ba8c8bc61480f32e55579ebf76630163405f7df39aa297b/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943", size = 95822, upload-time = "2025-06-06T07:00:26.666Z" }, + { url = "https://files.pythonhosted.org/packages/32/13/052be8b6642e6f509b30c194312b37bfee8b6b60ac3bd5ca2968c3ea5b80/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d", size = 89326, upload-time = "2025-06-06T07:00:27.477Z" }, + { url = "https://files.pythonhosted.org/packages/27/3d/421d04a20037370baf13de929bc1dc5438b306a76fe17275ec5d893aae6c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214", size = 92264, upload-time = "2025-09-20T07:09:53.563Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/43601ab82fe42bcff430b8466d84d92b31be06cc45c7fd64e9aac40f7851/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2", size = 97517, upload-time = "2025-09-20T07:09:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/e3303f6a25a2d98e424b06580fc85bbfd068f383424c67fa47cb1b357a46/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a", size = 94122, upload-time = "2025-09-20T07:09:55.187Z" }, + { url = "https://files.pythonhosted.org/packages/7c/12/ea0841207e6fc0cfbbfb54415930d30ad6dba77bc5a7cdbba20ba75ac1b7/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197", size = 89718, upload-time = "2025-06-06T07:00:29.915Z" }, + { url = "https://files.pythonhosted.org/packages/67/1d/77b69e74c1dd1a6e0dabc91150314abb56414fed265314aafde78cb01b91/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d", size = 96212, upload-time = "2025-06-06T07:00:31.199Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b6/0fd1d10358521b824c38409f81fe81a41f3ed4bd9d14a253d912c69d7d4a/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772", size = 89572, upload-time = "2025-06-06T07:00:32.023Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/a3/449ffc2f8e4c3cfbe7f14c1b43bcaa0475fbd2e8e8bf08465399c5ea078c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46", size = 182059, upload-time = "2025-06-06T07:00:47.095Z" }, + { url = "https://files.pythonhosted.org/packages/50/d9/6ea88731df569f5c1b086daf4c3496c8d43281588e3a578ea623fef6bc43/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed", size = 187866, upload-time = "2025-06-06T07:00:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2c/ace56fd32ad07608462de0ac7df218e0bf810e4cc31f2c0fbd7f5f90ee93/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde", size = 184627, upload-time = "2025-06-06T07:00:49.087Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312, upload-time = "2025-06-06T07:00:49.974Z" }, + { url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768, upload-time = "2025-06-06T07:00:50.853Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189, upload-time = "2025-06-06T07:00:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a1/75ac783a5faee9b455fef2f53b7fef97b21ed60d52401b44c690202141e4/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557", size = 183326, upload-time = "2025-06-06T07:00:52.662Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d9/a9dcc15322d2f5c7dfd491bd7ab121e36437caf78ebfa92bc0dd0546e2ca/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d", size = 187810, upload-time = "2025-06-06T07:00:53.594Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fc/47d00af076f558267097af3050910beda6bf8a21ceaa5830bbd26fcaf85e/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984", size = 184516, upload-time = "2025-06-06T07:00:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/93/30b45ce473d1a604908221a1fa035fe8d5e4bb9008e820ae671a21dab94c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0", size = 183342, upload-time = "2025-06-06T07:00:56.16Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3b/eb9d99b82a36002d7885206d00ea34f4a23db69c16c94816434ded728fa3/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30", size = 187844, upload-time = "2025-06-06T07:00:57.134Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/ebbbe9be9a3e640dcfc5f166eb48f2f9d8ce42553f83aa9f4c5dcd9eb5f5/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383", size = 184540, upload-time = "2025-06-06T07:00:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/cb447ca7730a1e05730272309b074da6a04af29a8c0f5121014db8a2fc02/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f", size = 185249, upload-time = "2025-09-20T07:09:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fa/f465d5d44dda166bf7ec64b7a950f57eca61f165bfe18345e9a5ea542def/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851", size = 193739, upload-time = "2025-09-20T07:09:59.893Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/51c53ac3c704cd92da5ed7e7b9b57159052f6e46744e4f7e447ed708aa22/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892", size = 194836, upload-time = "2025-09-20T07:10:00.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7b/363ba98d862ca84ca4b477cabf74cae0c13c0d6e219f9365dd772f25ab15/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a", size = 182901, upload-time = "2025-06-06T07:00:58.998Z" }, + { url = "https://files.pythonhosted.org/packages/d3/95/508ef65a093460ee74a78da1ec1fa482c0838f6b38bdd84c180e65976b9b/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86", size = 188634, upload-time = "2025-06-06T07:01:00.109Z" }, + { url = "https://files.pythonhosted.org/packages/43/cc/e22fa06423b646ea9ea474e1e27788be78f1bf37c2f9616dce343722523c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974", size = 185079, upload-time = "2025-06-06T07:01:01.034Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/61/2744d0e0b3fa7807149a1a36dd89abba901d6b24184d9fd5ef3f28467232/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5", size = 130040, upload-time = "2025-06-06T07:01:56.337Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f9/881b7ee8acdf3c9fe6c79d8ccd90f9246b397fc78420d55014c4ac05b822/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a", size = 142463, upload-time = "2025-06-06T07:01:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/12/db/b09dffcf1158b35d81d8d57bf19ad04293870cea5afa77943c87f1110d88/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e", size = 135871, upload-time = "2025-06-06T07:01:58.792Z" }, + { url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898, upload-time = "2025-06-06T07:01:59.687Z" }, + { url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361, upload-time = "2025-06-06T07:02:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808, upload-time = "2025-06-06T07:02:01.4Z" }, + { url = "https://files.pythonhosted.org/packages/31/3e/81642208ecd6c6c936f35a39a433c54e3f68e09d316546b8f953581ae334/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3", size = 130249, upload-time = "2025-06-06T07:02:02.237Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/a9ede5f3f0d86abfc7590726cf711133d97419b49ced372fca532e4f0696/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc", size = 141512, upload-time = "2025-06-06T07:02:03.424Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/4fad07c03124bdc3acd64f80f3bd3cc4417ea641e07bb16a9503afd3e554/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca", size = 135383, upload-time = "2025-06-06T07:02:04.312Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7d/ebd712ab8ccd599c593796fbcd606abe22b5a8e20db134aa87987d67ac0e/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9", size = 130276, upload-time = "2025-06-06T07:02:05.178Z" }, + { url = "https://files.pythonhosted.org/packages/70/de/f30daaaa0e6f4edb6bd7ddb3e058bd453c9ad90c032a4545c4d4639338aa/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015", size = 141536, upload-time = "2025-06-06T07:02:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/9a6aafdc74a085c550641a325be463bf4b811f6f605766c9cd4f4b5c19d2/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4", size = 135362, upload-time = "2025-06-06T07:02:06.997Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/5785cd1ec54dc0f0e6f3e6a466d07a62b8014a6e2b782e80444ef87e83ab/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106", size = 134252, upload-time = "2025-09-20T07:10:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/68d91068048410f49794c0b19c45759c63ca559607068cfe5affba2f211b/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472", size = 145509, upload-time = "2025-09-20T07:10:13.797Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a4/898951d5bfc474aa9c7d133fe30870f0f2184f4ba3027eafb779d30eb7bc/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22", size = 141334, upload-time = "2025-09-20T07:10:14.637Z" }, + { url = "https://files.pythonhosted.org/packages/56/ab/693f3d85eed14027aafd1f5e50c7f85cd0fce8425842735528f8b9a0b99e/winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588", size = 130257, upload-time = "2025-06-06T07:02:07.915Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b2/40d976e3c1252bb2c7893168ccc7b9ac07ef11a109f14d61bf6b687537e3/winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1", size = 143193, upload-time = "2025-06-06T07:02:08.772Z" }, + { url = "https://files.pythonhosted.org/packages/58/b5/76fb16c106e22dd6e39b9e1c4952c71097fa50799aacd7eb9f15de2802e2/winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58", size = 136098, upload-time = "2025-06-06T07:02:09.663Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/1b/0de659ed4bb80aee28753b4431011334205637a2578481a511866a11e0cf/winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78", size = 38643, upload-time = "2025-06-06T07:07:56.852Z" }, + { url = "https://files.pythonhosted.org/packages/29/fd/67c6db8a3244ecc95f85970a7b0e749cda28e26563db1274c3db36a8fbe4/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23", size = 40295, upload-time = "2025-06-06T07:07:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/15/6d/d145c7f90b01c24f4f9885d1f7d430ecaf2a2b42b6bc236701791b0b0a06/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837", size = 37060, upload-time = "2025-06-06T07:07:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a0/4a8b51da15de218cec04bcc1cd85b4b93bcfd8ebe50a5f0a7eee28836dc6/winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e", size = 38505, upload-time = "2025-06-06T07:07:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/de/49/ba69e3180585dbc6f3336a09fef7cba4558a6a1e7d500500f62c1478418e/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104", size = 40157, upload-time = "2025-06-06T07:07:59.948Z" }, + { url = "https://files.pythonhosted.org/packages/9c/92/64817f71a20ecf842da36dc3848f42614217688137a69c93fda8a6103155/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a", size = 36976, upload-time = "2025-06-06T07:08:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e0/4731a3c412318b2c5e74a8803a32e2fb9afc2c98368c6b61a422eb359e7e/winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb", size = 38606, upload-time = "2025-06-06T07:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/91464854dfc9e0be9ce8dcbe2bd6a67c19b68ab91584fc5de0f4f13e78f8/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e", size = 40172, upload-time = "2025-06-06T07:08:02.214Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0d/1bd62f606b6c4dfa936fccc4712be5506a40fc5d1b7177c3d3cbcaf30972/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1", size = 36989, upload-time = "2025-06-06T07:08:03.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/94/c22a14fd424632f3f3c0b25672218db9e8f4ae9e1355e0b148f2fe6015b5/winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8", size = 38613, upload-time = "2025-06-06T07:08:04.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/24cec0cc228642554b48d436a7617d7162fb952919c55fc26e2d99c310bd/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290", size = 40180, upload-time = "2025-06-06T07:08:04.849Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/776453af26e78c0d0c0e1bfa89f86fd81322872f31a3e5dafb344dd47bf2/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f", size = 36989, upload-time = "2025-06-06T07:08:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/76/79/4627afae6b389ddd1e5f1d691663c6b14d6c8f98959082aed1217cc57ef9/winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6", size = 39521, upload-time = "2025-09-20T07:11:17.992Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/c6aea91908ee7279ed51d12157bc8aeecb8850af2441073c3c91b261ad31/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf", size = 41121, upload-time = "2025-09-20T07:11:18.747Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/652f14e3c501452ad8e0723518d9bbd729219b47f4a4dbe2966c2f82dca8/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312", size = 38114, upload-time = "2025-09-20T07:11:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/48/dc96099a9eda28bcbc9b65d70b65841ee8a9be78d671c0ba740b7b1e6c73/winrt_windows_devices_radios-3.2.1-cp39-cp39-win32.whl", hash = "sha256:59b868d45ff22afad21b0b0d1466ec43e54543c4e4c6f1efcc2d4adc77053bd5", size = 38653, upload-time = "2025-06-06T07:08:06.372Z" }, + { url = "https://files.pythonhosted.org/packages/a7/de/5476331687adbd7cfa46c44dcbb29f98422e5333f722e307005983663ab6/winrt_windows_devices_radios-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:dbfcbb977f60f19c852204987ace0cd6f7a432d735882a45b3074fdbfd3fdb5a", size = 40380, upload-time = "2025-06-06T07:08:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/40/85/c376bf22c5026c9965be756e194d654bf52cc54a574b4caea75861e2adf4/winrt_windows_devices_radios-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:659e07e6aa5542587ccfc4d4e2cc6e1ef0869606c867a3e95fc82cc8aeaf1f81", size = 37135, upload-time = "2025-06-06T07:08:07.869Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/d387332c4378b41f87211b7dc40a4cfc6b7047dc227448aaa207624fc911/winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b", size = 111969, upload-time = "2025-06-06T07:10:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/046c1e2424627c3db66d764871186de4d26936e8a138d6bf04dc143e4606/winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39", size = 118695, upload-time = "2025-06-06T07:10:56.782Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2e/2463bc4ad984836fb3ecf1abac62df67bc5cabab004cad09b828b86ed51b/winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4", size = 109690, upload-time = "2025-06-06T07:10:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944, upload-time = "2025-06-06T07:10:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465, upload-time = "2025-06-06T07:10:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609, upload-time = "2025-06-06T07:11:00.54Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/495e304ddedd5ff2f196efbde906265cb75ade4d79e2937837f72ef654a0/winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b", size = 112169, upload-time = "2025-06-06T07:11:01.438Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5e/b5059e4ece095351c496c9499783130c302d25e353c18031d5231b1b3b3c/winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9", size = 118668, upload-time = "2025-06-06T07:11:02.475Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/acbcb3ef07b1b67e2de4afab9176a5282cfd775afd073efe6828dfc65ace/winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37", size = 109671, upload-time = "2025-06-06T07:11:03.538Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d0/87ed35b78f143f282fb65775fa8845106ec8bbfdad7fca1ae8a7f8740064/winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b", size = 112280, upload-time = "2025-06-06T07:11:07.241Z" }, + { url = "https://files.pythonhosted.org/packages/19/71/617c10dee1d6c6541ea4215229aa4aad0373fffa565ce788790b90369d41/winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5", size = 119479, upload-time = "2025-06-06T07:11:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2c/235a54e2746a868f6fc9f563b03d95c5974038e3d37bccd5b541442c0450/winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705", size = 110247, upload-time = "2025-06-06T07:11:08.98Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/26/ed3d35ea262999d28be957c35a32e93360eac0ef9f14e75d32cd6b5c6a37/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389", size = 59880, upload-time = "2025-06-06T07:11:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/b4a1aeba2d13c1f2ad3d851d5092b8397c05f34fb318d6a7d499f5b5720b/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6", size = 70650, upload-time = "2025-06-06T07:11:11.396Z" }, + { url = "https://files.pythonhosted.org/packages/9f/74/f8a4a29202da24f2af2c4a8f515b0a44fe46bc4d25b3d54ea2249e980bd3/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6", size = 59216, upload-time = "2025-06-06T07:11:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693, upload-time = "2025-06-06T07:11:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828, upload-time = "2025-06-06T07:11:14.54Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051, upload-time = "2025-06-06T07:11:15.379Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0b/7802349391466d3f7e8f62f588f36a1a0b6560abfcdbdaa426fe21d322b4/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6", size = 60060, upload-time = "2025-06-06T07:11:16.173Z" }, + { url = "https://files.pythonhosted.org/packages/37/94/5b888713e472746635a382e523513ab1b8200af55c5b56bc70e1e4369115/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b", size = 69058, upload-time = "2025-06-06T07:11:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/829273622c9b37c67b97f187b92be318404f7d33db045e31d72b7d50f54c/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84", size = 58793, upload-time = "2025-06-06T07:11:17.837Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063, upload-time = "2025-06-06T07:11:18.65Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057, upload-time = "2025-06-06T07:11:19.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792, upload-time = "2025-06-06T07:11:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3c/69cd1a35df8120556b96e15b3c2cb844b227939bb286a4828aa3419f713b/winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563", size = 60260, upload-time = "2025-06-06T07:11:21.036Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fb/a044f0b377a21491a8211ab675213672494b290fdc155f443f07ad900f91/winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111", size = 71293, upload-time = "2025-06-06T07:11:21.878Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/ed1096fef5d2663715ef150a2af79cfcf52ae86d10dd362440a832b77059/winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3", size = 59509, upload-time = "2025-06-06T07:11:22.694Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4d/a0d806f4664b9bcf525bd31dcdf1f9520cc14f033e897dc7f7dd4ad4eb77/winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003", size = 127791, upload-time = "2025-06-06T14:01:56.192Z" }, + { url = "https://files.pythonhosted.org/packages/99/2c/00baa87041a3d92a3cc5230d4033e995a52740e9c08fcd9f7bde93cb979f/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00", size = 132608, upload-time = "2025-06-06T14:01:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ed03e864aa8eaaec964d5bbc95baccf738275ae6cc88600db66ecb5adaf4/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6", size = 128495, upload-time = "2025-06-06T14:01:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509, upload-time = "2025-06-06T14:01:58.971Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491, upload-time = "2025-06-06T14:01:59.788Z" }, + { url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577, upload-time = "2025-06-06T14:02:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/e7/7d3f2a4a442f264e05cab2bdf20ed1b95cb3f753bd1b0f277f2b49fb8335/winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78", size = 127787, upload-time = "2025-06-06T14:02:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2f/cc36f475f8af293f40e2c2a5d6c2e75a189c2c2d4d01ecb3551578518c79/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc", size = 131849, upload-time = "2025-06-06T14:02:03.09Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/896fb734f7456910ec412f3f3adfdc3f0dc3134864a496d5b120592f3bfd/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307", size = 128144, upload-time = "2025-06-06T14:02:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d2/24d9f59bdc05e741261d5bec3bcea9a848d57714126a263df840e2b515a8/winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163", size = 127774, upload-time = "2025-06-06T14:02:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/601724453b885265c7779d5f8025b043a68447cbc64ceb9149d674d5b724/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915", size = 131827, upload-time = "2025-06-06T14:02:05.601Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/a419675a6087c9ea496968c9b7805ef234afa585b7483e2269608a12b044/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d", size = 128180, upload-time = "2025-06-06T14:02:06.759Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/2869ea2112c565caace73c9301afd1d7afcc49bdd37fac058f0178ba95d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee", size = 131701, upload-time = "2025-09-20T07:17:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/7a/74/5313b13e0c390c9066bb0f78d1e92b78f588ef123cd7da4ceae6d887ca9e/winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627", size = 128256, upload-time = "2025-06-06T14:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/edf73f0fb9ec342942dd7867682c4a85a6ecc25eeb212aef0d64f91667aa/winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657", size = 133724, upload-time = "2025-06-06T14:02:08.876Z" }, + { 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" }, +]