From ecab2a9a999a2de3c6027eae43d36c5d643c5e88 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 01:41:52 -0700 Subject: [PATCH 01/23] =?UTF-8?q?chore:=20bootstrap=20SDK=20redesign=20?= =?UTF-8?q?=E2=80=94=20remove=20SyncSession,=20restructure=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete obsolete SyncSession capture.py and its tests. Reorganize tests into unit/ and integration/ subdirectories. Create adapters/ package. Update pyproject.toml with optional extras for audio/uvc/ble backends. Breaking change: SyncSession is removed. New SessionOrchestrator API replaces it in subsequent tasks. --- pyproject.toml | 16 +- src/syncfield/__init__.py | 19 +- src/syncfield/adapters/__init__.py | 0 src/syncfield/capture.py | 262 -------- tests/integration/__init__.py | 0 tests/test_capture.py | 421 ------------ tests/unit/__init__.py | 0 tests/unit/adapters/__init__.py | 0 tests/{ => unit}/test_types.py | 0 tests/{ => unit}/test_writer.py | 4 +- uv.lock | 998 ++++++++++++++++++++++++++++- 11 files changed, 1011 insertions(+), 709 deletions(-) create mode 100644 src/syncfield/adapters/__init__.py delete mode 100644 src/syncfield/capture.py create mode 100644 tests/integration/__init__.py delete mode 100644 tests/test_capture.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/adapters/__init__.py rename tests/{ => unit}/test_types.py (100%) rename tests/{ => unit}/test_writer.py (97%) diff --git a/pyproject.toml b/pyproject.toml index 8b13081..433f014 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,12 @@ classifiers = [ "Topic :: Scientific/Engineering", ] +[project.optional-dependencies] +audio = ["sounddevice>=0.4.6"] +uvc = ["opencv-python>=4.5"] +ble = ["bleak>=0.21"] +all = ["sounddevice>=0.4.6", "opencv-python>=4.5", "bleak>=0.21"] + [project.urls] Homepage = "https://opengraphlabs.com/" Repository = "https://github.com/OpenGraphLabs/syncfield-python" @@ -34,8 +40,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/src/syncfield/__init__.py b/src/syncfield/__init__.py index 609826a..a361e3d 100644 --- a/src/syncfield/__init__.py +++ b/src/syncfield/__init__.py @@ -1,22 +1,9 @@ -"""SyncField — lightweight timestamp capture for multi-stream synchronization. +"""SyncField — capture orchestration framework for multi-modal synchronization. -Quick start:: - - import syncfield as sf - - session = sf.SyncSession(host_id="rig_01", output_dir="./timestamps") - session.start() - - frame = camera.read() - session.stamp("cam_left", frame_number=0) - - session.stop() +This module's public API is being rebuilt. Imports will be added as tasks complete. """ 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"] __version__ = _pkg_version("syncfield") +__all__: list[str] = [] diff --git a/src/syncfield/adapters/__init__.py b/src/syncfield/adapters/__init__.py new file mode 100644 index 0000000..e69de29 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/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 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/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/test_types.py b/tests/unit/test_types.py similarity index 100% rename from tests/test_types.py rename to tests/unit/test_types.py diff --git a/tests/test_writer.py b/tests/unit/test_writer.py similarity index 97% rename from tests/test_writer.py rename to tests/unit/test_writer.py index f906c31..e833a4d 100644 --- a/tests/test_writer.py +++ b/tests/unit/test_writer.py @@ -49,7 +49,7 @@ def test_write_sync_point(tmp_path: Path): assert path == tmp_path / "sync_point.json" data = json.loads(path.read_text()) - assert data["sdk_version"] == "0.1.0" + assert data["sdk_version"] == "0.2.0" assert data["host_id"] == "test" assert data["monotonic_ns"] == 111 assert data["wall_clock_ns"] == 222 @@ -119,7 +119,7 @@ def test_write_manifest(tmp_path: Path): assert path == tmp_path / "manifest.json" data = json.loads(path.read_text()) - assert data["sdk_version"] == "0.1.0" + assert data["sdk_version"] == "0.2.0" assert data["host_id"] == "test_host" assert "streams" in data assert data["streams"]["cam_left"]["type"] == "video" diff --git a/uv.lock b/uv.lock index aee0239..028d9e2 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,6 +178,87 @@ 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" @@ -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,81 @@ 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 = "opencv-python" }, + { name = "sounddevice" }, +] +audio = [ + { 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 = "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 +925,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" }, +] From e82dbac8ca60534144fc6fe2807a66fc27998331 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 01:52:34 -0700 Subject: [PATCH 02/23] =?UTF-8?q?feat(types):=20add=20SPI=20types=20?= =?UTF-8?q?=E2=80=94=20StreamCapabilities,=20SessionState,=20HealthEvent,?= =?UTF-8?q?=20SampleEvent,=20FinalizationReport,=20ChirpSpec,=20SessionRep?= =?UTF-8?q?ort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new dataclasses and enums for the SessionOrchestrator + Stream SPI. Existing SyncPoint/FrameTimestamp/SensorSample types are preserved unchanged to keep output file compatibility with the sync core. --- src/syncfield/types.py | 165 ++++++++++++++++++++++++++++++++++++++- tests/unit/test_types.py | 154 ++++++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+), 1 deletion(-) 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/tests/unit/test_types.py b/tests/unit/test_types.py index 93d4648..7fe4e89 100644 --- a/tests/unit/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 == [] From 8ddfa8b1495cf3069cb22b8670fa24bcd0609548 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 01:58:21 -0700 Subject: [PATCH 03/23] feat(clock): add SessionClock primitive Immutable clock handle distributed to Streams at session.start(). Wraps the session's SyncPoint and provides now_ns / elapsed_ns helpers so streams don't import time directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/clock.py | 49 ++++++++++++++++++++++++++++++++++++++++ tests/unit/test_clock.py | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/syncfield/clock.py create mode 100644 tests/unit/test_clock.py 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/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] From 708d175eb21af735095518c15fff54e616d287e8 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 01:59:46 -0700 Subject: [PATCH 04/23] feat(writer): extend sync_point + add SessionLogWriter - write_sync_point accepts optional chirp_start_ns / chirp_stop_ns / chirp_spec and omits fields when None (clean output for silent/single-host sessions) - SessionLogWriter writes orchestrator-level events (state transitions, health, rollbacks) to session_log.jsonl with per-write flush for crash safety - write_manifest stores per-stream capabilities via the existing streams dict Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/writer.py | 127 +++++++++++++++++++++++++++++++++++--- tests/unit/test_writer.py | 118 ++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 10 deletions(-) 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/unit/test_writer.py b/tests/unit/test_writer.py index e833a4d..e9d319a 100644 --- a/tests/unit/test_writer.py +++ b/tests/unit/test_writer.py @@ -3,8 +3,22 @@ import json from pathlib import Path -from syncfield.types import FrameTimestamp, SensorSample, SyncPoint -from syncfield.writer import SensorWriter, StreamWriter, write_manifest, write_sync_point +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): @@ -123,3 +137,103 @@ def test_write_manifest(tmp_path: Path): 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" From d004f94f93f19481843c2cf0644ff04c7ab3a80b Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:00:42 -0700 Subject: [PATCH 05/23] feat(tone): add stdlib-only linear FM chirp generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_chirp_samples produces mono float PCM for a linear FM sweep with cosine envelope, matching the egonaut SoundFeedbackModule design (400↔2500Hz, 500ms, 15ms envelope). Pure Python math — no numpy dependency — so the core SDK stays lightweight. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/tone.py | 75 +++++++++++++++++++++++++++++++++++++++++ tests/unit/test_tone.py | 51 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/syncfield/tone.py create mode 100644 tests/unit/test_tone.py diff --git a/src/syncfield/tone.py b/src/syncfield/tone.py new file mode 100644 index 0000000..74dad99 --- /dev/null +++ b/src/syncfield/tone.py @@ -0,0 +1,75 @@ +"""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 math +from typing import List + +from syncfield.types import ChirpSpec + + +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 diff --git a/tests/unit/test_tone.py b/tests/unit/test_tone.py new file mode 100644 index 0000000..9bd4624 --- /dev/null +++ b/tests/unit/test_tone.py @@ -0,0 +1,51 @@ +"""Tests for sync tone generation, serialization, and playback.""" + +from __future__ import annotations + +from syncfield.tone import generate_chirp_samples +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) == [] From 38288a41002e2b653c9ceb9925f9e1cdcaf05e87 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:01:23 -0700 Subject: [PATCH 06/23] feat(tone): add write_chirp_wav for 16-bit PCM serialization Used by playback backends (sounddevice or system audio) and for debugging chirp signals. Clamps floats to [-1, 1] before int16 conversion so amplitude overflows never corrupt output. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/tone.py | 40 ++++++++++++++++++++++++++++++++++++++++ tests/unit/test_tone.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/syncfield/tone.py b/src/syncfield/tone.py index 74dad99..a6e4494 100644 --- a/src/syncfield/tone.py +++ b/src/syncfield/tone.py @@ -16,6 +16,9 @@ from __future__ import annotations import math +import struct +import wave +from pathlib import Path from typing import List from syncfield.types import ChirpSpec @@ -73,3 +76,40 @@ def generate_chirp_samples(spec: ChirpSpec, sample_rate: int = 44100) -> List[fl 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 diff --git a/tests/unit/test_tone.py b/tests/unit/test_tone.py index 9bd4624..591c37c 100644 --- a/tests/unit/test_tone.py +++ b/tests/unit/test_tone.py @@ -2,7 +2,10 @@ from __future__ import annotations -from syncfield.tone import generate_chirp_samples +import struct +import wave + +from syncfield.tone import generate_chirp_samples, write_chirp_wav from syncfield.types import ChirpSpec @@ -49,3 +52,32 @@ def test_silent_when_amplitude_zero(self): 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 From e1b5977babc6875a9374d9d38687abcb1fcc79c6 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:02:17 -0700 Subject: [PATCH 07/23] feat(tone): add SyncToneConfig with egonaut defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frozen dataclass with default() and silent() factory methods. Defaults match the egonaut production chirp spec (400↔2500Hz, 500ms, 0.8 amplitude, 15ms envelope, 200ms stabilization/tail margins). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/tone.py | 73 +++++++++++++++++++++++++++++++++++++++++ tests/unit/test_tone.py | 42 +++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/syncfield/tone.py b/src/syncfield/tone.py index a6e4494..a29ca99 100644 --- a/src/syncfield/tone.py +++ b/src/syncfield/tone.py @@ -18,12 +18,31 @@ import math import struct import wave +from dataclasses import dataclass, field from pathlib import Path from typing import List from syncfield.types import ChirpSpec +# --------------------------------------------------------------------------- +# 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. @@ -113,3 +132,57 @@ def write_chirp_wav( 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) diff --git a/tests/unit/test_tone.py b/tests/unit/test_tone.py index 591c37c..2b1fd81 100644 --- a/tests/unit/test_tone.py +++ b/tests/unit/test_tone.py @@ -2,10 +2,13 @@ from __future__ import annotations +import dataclasses import struct import wave -from syncfield.tone import generate_chirp_samples, write_chirp_wav +import pytest + +from syncfield.tone import SyncToneConfig, generate_chirp_samples, write_chirp_wav from syncfield.types import ChirpSpec @@ -81,3 +84,40 @@ 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 From 902b98a313f427bd64056dd83fcb22f139acf0c4 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:03:16 -0700 Subject: [PATCH 08/23] feat(tone): add ChirpPlayer protocol with sounddevice + silent backends - ChirpPlayer is a runtime_checkable Protocol - SoundDeviceChirpPlayer wraps the optional sounddevice library (non-blocking) - SilentChirpPlayer is a no-op fallback for headless machines - create_default_player chooses the right backend at runtime, logging at INFO when sounddevice is unavailable (never raises) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/tone.py | 94 ++++++++++++++++++++++++++++++++++++++++- tests/unit/test_tone.py | 68 ++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/src/syncfield/tone.py b/src/syncfield/tone.py index a29ca99..17b1089 100644 --- a/src/syncfield/tone.py +++ b/src/syncfield/tone.py @@ -15,15 +15,18 @@ from __future__ import annotations +import logging import math import struct import wave from dataclasses import dataclass, field from pathlib import Path -from typing import List +from typing import List, Protocol, runtime_checkable from syncfield.types import ChirpSpec +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Defaults @@ -186,3 +189,92 @@ def silent(cls) -> "SyncToneConfig": 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 import keeps the module importable on machines that lack + # sounddevice — only instantiating this class requires the dep. + import sounddevice as sd # type: ignore[import-not-found] + + samples = generate_chirp_samples(spec, sample_rate=self._sample_rate) + sd.play(samples, 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/tests/unit/test_tone.py b/tests/unit/test_tone.py index 2b1fd81..7925313 100644 --- a/tests/unit/test_tone.py +++ b/tests/unit/test_tone.py @@ -4,11 +4,21 @@ import dataclasses import struct +import sys import wave +from unittest.mock import MagicMock, patch import pytest -from syncfield.tone import SyncToneConfig, generate_chirp_samples, write_chirp_wav +from syncfield.tone import ( + ChirpPlayer, + SilentChirpPlayer, + SoundDeviceChirpPlayer, + SyncToneConfig, + create_default_player, + generate_chirp_samples, + write_chirp_wav, +) from syncfield.types import ChirpSpec @@ -121,3 +131,59 @@ def test_custom_values_round_trip(self): ) 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 From c775c8a0b741937635562129c95b9f0c2a756d9f Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:04:12 -0700 Subject: [PATCH 09/23] feat(stream): add Stream protocol and StreamBase helper - Stream is a runtime_checkable Protocol describing the SPI contract - StreamBase provides callback registration and health buffering so concrete adapters only implement prepare/start/stop - Sample and health events flow to all registered callbacks; health events also accumulate in an internal buffer used by FinalizationReport Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/stream.py | 130 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_stream.py | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/syncfield/stream.py create mode 100644 tests/unit/test_stream.py 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/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 From 2fe88ff87a3e064cebfdfeef91e8ab5aeca7031a Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:04:55 -0700 Subject: [PATCH 10/23] feat(testing): add FakeStream utility for orchestrator tests Programmable in-memory Stream with lifecycle tracking, sample/health injection, and failure-injection flags. Ships in syncfield.testing so third-party adapter authors can reuse it for their own tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/testing.py | 113 +++++++++++++++++++++++++++++++++ tests/unit/test_fake_stream.py | 86 +++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/syncfield/testing.py create mode 100644 tests/unit/test_fake_stream.py 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/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 From 335c73893297d29c6b4a7d557f34ad4b606aeb57 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:05:47 -0700 Subject: [PATCH 11/23] feat(orchestrator): add SessionOrchestrator skeleton with add() Class definition, state enum, add() with duplicate-id rejection, output directory creation on construction. Thread-safe via internal RLock; start/stop lifecycle methods added in subsequent tasks. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 94 +++++++++++++++++++++++++++++++++ tests/unit/test_orchestrator.py | 51 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/syncfield/orchestrator.py create mode 100644 tests/unit/test_orchestrator.py diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py new file mode 100644 index 0000000..fe95b90 --- /dev/null +++ b/src/syncfield/orchestrator.py @@ -0,0 +1,94 @@ +"""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 manifest flushing, 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. + +This skeleton is expanded incrementally — the ``start()``/``stop()`` logic, +chirp integration, crash-safe session log, and health routing are added in +subsequent tasks. + +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 threading +from pathlib import Path +from typing import Dict + +from syncfield.stream import Stream +from syncfield.tone import SyncToneConfig +from syncfield.types import SessionState + + +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, + ) -> 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._streams: Dict[str, Stream] = {} + self._state = SessionState.IDLE + self._lock = threading.RLock() + + # ------------------------------------------------------------------ + # 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. + + 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 diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py new file mode 100644 index 0000000..27bc3ba --- /dev/null +++ b/tests/unit/test_orchestrator.py @@ -0,0 +1,51 @@ +"""Tests for SessionOrchestrator lifecycle and behavior.""" + +from __future__ import annotations + +import pytest + +from syncfield.orchestrator import SessionOrchestrator +from syncfield.testing import FakeStream +from syncfield.tone import SyncToneConfig +from syncfield.types import SessionState + + +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")) From 1afd1461e65a37ba3bdc60ba0f14f7087cb0c90d Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:07:05 -0700 Subject: [PATCH 12/23] feat(orchestrator): implement start() with atomic rollback - Captures a SyncPoint and builds a shared SessionClock - Calls prepare() then start() on every registered stream sequentially - On any failure: rolls back previously-started streams in reverse order (best-effort stop), returns state to IDLE, re-raises the original exception - The failed stream itself is NOT rolled back since it never reached a successfully-started state Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 78 ++++++++++++++++++++++++++- tests/unit/test_orchestrator.py | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index fe95b90..40d11be 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -21,11 +21,12 @@ import threading from pathlib import Path -from typing import Dict +from typing import Dict, List +from syncfield.clock import SessionClock from syncfield.stream import Stream from syncfield.tone import SyncToneConfig -from syncfield.types import SessionState +from syncfield.types import SessionState, SyncPoint class SessionOrchestrator: @@ -55,6 +56,10 @@ def __init__( self._state = SessionState.IDLE self._lock = threading.RLock() + # Populated during start(); consumed during stop(). + self._sync_point: SyncPoint | None = None + self._session_clock: SessionClock | None = None + # ------------------------------------------------------------------ # Public properties # ------------------------------------------------------------------ @@ -92,3 +97,72 @@ def add(self, stream: Stream) -> None: if stream.id in self._streams: raise ValueError(f"duplicate stream id: {stream.id!r}") self._streams[stream.id] = stream + + # ------------------------------------------------------------------ + # 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") + + self._state = 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: + self._rollback_started_streams(started) + self._state = SessionState.IDLE + raise + + self._state = 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 diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 27bc3ba..8c1ed1e 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -4,6 +4,7 @@ import pytest +from syncfield.clock import SessionClock from syncfield.orchestrator import SessionOrchestrator from syncfield.testing import FakeStream from syncfield.tone import SyncToneConfig @@ -49,3 +50,97 @@ def test_rejects_duplicate_stream_id(self, 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 From 64c6e724b6cea5f84fa86c380c3828816eb4707d Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:08:14 -0700 Subject: [PATCH 13/23] feat(orchestrator): implement stop() sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calls stop() on each stream (best-effort — a broken stream becomes a failed FinalizationReport instead of blocking the others), writes sync_point.json and manifest.json with per-stream capabilities, and transitions through STOPPING to STOPPED. Returns an aggregated SessionReport. Logic is split into _finalize_streams and _persist_session_artifacts helpers to keep stop() itself readable as a state-machine overview. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 113 +++++++++++++++++++++++++++++++- tests/unit/test_orchestrator.py | 79 ++++++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 40d11be..4a2c75f 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -26,7 +26,13 @@ from syncfield.clock import SessionClock from syncfield.stream import Stream from syncfield.tone import SyncToneConfig -from syncfield.types import SessionState, SyncPoint +from syncfield.types import ( + FinalizationReport, + SessionReport, + SessionState, + SyncPoint, +) +from syncfield.writer import write_manifest, write_sync_point class SessionOrchestrator: @@ -166,3 +172,108 @@ def _rollback_started_streams(started: List[Stream]) -> None: 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. For each stream, call ``stop()``. Exceptions become failed + :class:`FinalizationReport` entries — one slow or broken + stream must never block finalization of the others. + 3. Write ``sync_point.json`` and ``manifest.json`` to the + output directory. + 4. Transition to ``STOPPED`` and return the aggregated + :class:`SessionReport`. + + Chirp playback integration is added in a subsequent task; this + base implementation handles only the non-chirp finalization path. + + Returns: + Aggregated :class:`SessionReport` with per-stream + finalization reports. + + 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._state = SessionState.STOPPING + + finalizations = self._finalize_streams() + self._persist_session_artifacts(finalizations) + + self._state = SessionState.STOPPED + return SessionReport( + host_id=self._host_id, + finalizations=finalizations, + chirp_start_ns=None, + chirp_stop_ns=None, + ) + + 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()``. + """ + assert self._sync_point is not None # guaranteed by state check + + write_sync_point(self._sync_point, self._output_dir) + + 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) diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 8c1ed1e..7c8b941 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json + import pytest from syncfield.clock import SessionClock @@ -144,3 +146,80 @@ def test_failure_during_prepare_stops_earlier_streams(self, tmp_path): 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 From 3a69d633a7516bf89cad68452123b7ca7ff899d1 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:09:49 -0700 Subject: [PATCH 14/23] feat(orchestrator): integrate audio sync chirp - Chirp eligibility is host-level: requires at least one registered stream with provides_audio_track=True. When no audio stream exists, log INFO and skip the chirp. Single-host sessions are unaffected. - Start chirp plays after all streams start, following post_start_stabilization_ms. - Stop chirp plays BEFORE stream.stop() so it is captured in recording audio tracks; orchestrator then sleeps duration + pre_stop_tail_margin_ms. - chirp_start_ns / chirp_stop_ns / chirp_spec flow to sync_point.json and to the returned SessionReport when a chirp was played. - Split into _maybe_play_start_chirp / _maybe_play_stop_chirp_and_wait so start() and stop() read as clean state-machine overviews. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 97 ++++++++++++++++++++++++++++++--- tests/unit/test_orchestrator.py | 93 ++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 9 deletions(-) diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 4a2c75f..f67af9f 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -19,13 +19,15 @@ from __future__ import annotations +import logging import threading +import time from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional from syncfield.clock import SessionClock from syncfield.stream import Stream -from syncfield.tone import SyncToneConfig +from syncfield.tone import ChirpPlayer, SyncToneConfig, create_default_player from syncfield.types import ( FinalizationReport, SessionReport, @@ -34,6 +36,8 @@ ) from syncfield.writer import write_manifest, write_sync_point +logger = logging.getLogger(__name__) + class SessionOrchestrator: """Coordinates a multi-stream recording session for one host. @@ -53,18 +57,22 @@ def __init__( 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: SyncPoint | None = None - self._session_clock: SessionClock | None = None + 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 # ------------------------------------------------------------------ # Public properties @@ -155,6 +163,7 @@ def start(self) -> None: self._state = SessionState.IDLE raise + self._maybe_play_start_chirp() self._state = SessionState.RECORDING @staticmethod @@ -208,6 +217,8 @@ def stop(self) -> SessionReport: ) self._state = SessionState.STOPPING + self._maybe_play_stop_chirp_and_wait() + finalizations = self._finalize_streams() self._persist_session_artifacts(finalizations) @@ -215,8 +226,8 @@ def stop(self) -> SessionReport: return SessionReport( host_id=self._host_id, finalizations=finalizations, - chirp_start_ns=None, - chirp_stop_ns=None, + chirp_start_ns=self._chirp_start_ns, + chirp_stop_ns=self._chirp_stop_ns, ) def _finalize_streams(self) -> List[FinalizationReport]: @@ -253,11 +264,22 @@ def _persist_session_artifacts( Assumes ``start()`` has already captured ``self._sync_point``; safe because ``stop()`` requires ``RECORDING`` state which can - only be entered through ``start()``. + 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 - write_sync_point(self._sync_point, self._output_dir) + 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} @@ -277,3 +299,62 @@ def _persist_session_artifacts( streams_dict[stream.id] = entry write_manifest(self._host_id, streams_dict, self._output_dir) + + # ------------------------------------------------------------------ + # 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/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 7c8b941..66eae27 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -3,16 +3,28 @@ 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 SyncToneConfig +from syncfield.tone import ChirpPlayer, ChirpSpec, SyncToneConfig from syncfield.types import 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( @@ -223,3 +235,82 @@ def test_failing_stream_does_not_block_other_stops(self, tmp_path): 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 From 6549752bcea29f6286840c53f0c99dd8be062822 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:10:57 -0700 Subject: [PATCH 15/23] feat(orchestrator): crash-safe session log - Opens SessionLogWriter during start() BEFORE any state change so failures are still recorded on disk - Every state transition flows through _transition() which logs immediately and flushes per-write, giving a recoverable timeline even if the process dies mid-recording - Rollbacks are logged with the failing exception message and the number of streams that had to be torn down Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 61 +++++++++++++++++++++++++++++---- tests/unit/test_orchestrator.py | 41 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index f67af9f..4b6be00 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -34,7 +34,7 @@ SessionState, SyncPoint, ) -from syncfield.writer import write_manifest, write_sync_point +from syncfield.writer import SessionLogWriter, write_manifest, write_sync_point logger = logging.getLogger(__name__) @@ -73,6 +73,7 @@ def __init__( 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 @@ -148,7 +149,12 @@ def start(self) -> None: if not self._streams: raise RuntimeError("cannot start() with no streams registered") - self._state = SessionState.PREPARING + # 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) @@ -158,13 +164,14 @@ def start(self) -> None: stream.prepare() stream.start(self._session_clock) started.append(stream) - except Exception: + except Exception as exc: + self._log_rollback(exc, len(started)) self._rollback_started_streams(started) - self._state = SessionState.IDLE + self._transition(SessionState.IDLE) raise self._maybe_play_start_chirp() - self._state = SessionState.RECORDING + self._transition(SessionState.RECORDING) @staticmethod def _rollback_started_streams(started: List[Stream]) -> None: @@ -215,14 +222,17 @@ def stop(self) -> SessionReport: raise RuntimeError( f"stop() requires RECORDING state; current state is {self._state.value}" ) - self._state = SessionState.STOPPING + self._transition(SessionState.STOPPING) self._maybe_play_stop_chirp_and_wait() finalizations = self._finalize_streams() self._persist_session_artifacts(finalizations) - self._state = SessionState.STOPPED + 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, @@ -300,6 +310,43 @@ def _persist_session_artifacts( 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(), + } + ) + # ------------------------------------------------------------------ # Chirp injection # ------------------------------------------------------------------ diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 66eae27..681da23 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -314,3 +314,44 @@ def test_session_report_carries_chirp_timestamps(self, tmp_path): 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) From 2fae94e7779c77acfe2330a90bba7d2c3f91f70a Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:11:52 -0700 Subject: [PATCH 16/23] feat(orchestrator): route stream health events to session log - Every stream registered via add() has its on_health callback wired to the orchestrator's session log writer - Drops, reconnects, warnings, and errors flow into session_log.jsonl so the core can enrich sync_report.json with observability data - Health events emitted before start() are still buffered by StreamBase and surface through the FinalizationReport, so nothing is lost even in the unusual case of early emission Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 17 ++++++++++++- tests/unit/test_orchestrator.py | 42 ++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 4b6be00..306cb9d 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -30,6 +30,7 @@ from syncfield.tone import ChirpPlayer, SyncToneConfig, create_default_player from syncfield.types import ( FinalizationReport, + HealthEvent, SessionReport, SessionState, SyncPoint, @@ -99,7 +100,9 @@ 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. + 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. @@ -112,6 +115,7 @@ def add(self, stream: Stream) -> None: 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 @@ -347,6 +351,17 @@ def _log_rollback(self, exc: BaseException, started_count: int) -> None: } ) + 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 # ------------------------------------------------------------------ diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 681da23..cff2e11 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -11,7 +11,7 @@ from syncfield.orchestrator import SessionOrchestrator from syncfield.testing import FakeStream from syncfield.tone import ChirpPlayer, ChirpSpec, SyncToneConfig -from syncfield.types import SessionState +from syncfield.types import HealthEventKind, SessionState def _fast_chirp_config() -> SyncToneConfig: @@ -355,3 +355,43 @@ def test_rollback_is_logged(self, tmp_path): 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 + ) From 84b17d149f25280d12ee26fc14c975fb249fd815 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:12:40 -0700 Subject: [PATCH 17/23] feat(adapters): add JSONLFileStream degenerate adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps a customer-owned JSONL file as a Stream. No I/O of its own during recording — just tracks lifecycle and counts lines on stop(). Missing file yields status='partial'. Useful for customers who already have their own sample-writing code and just want orchestrator coordination. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/jsonl_file.py | 81 ++++++++++++++++++++++++++ tests/unit/adapters/test_jsonl_file.py | 65 +++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/syncfield/adapters/jsonl_file.py create mode 100644 tests/unit/adapters/test_jsonl_file.py 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/tests/unit/adapters/test_jsonl_file.py b/tests/unit/adapters/test_jsonl_file.py new file mode 100644 index 0000000..28eb5be --- /dev/null +++ b/tests/unit/adapters/test_jsonl_file.py @@ -0,0 +1,65 @@ +"""Unit tests for JSONLFileStream adapter.""" + +from __future__ import annotations + +import json + +import pytest + +from syncfield.adapters.jsonl_file import JSONLFileStream +from syncfield.clock import SessionClock +from syncfield.types import SyncPoint + + +def _clock() -> 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 From 4257b011429836bc77ba1063fa4551782509a62c Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:13:40 -0700 Subject: [PATCH 18/23] feat(adapters): add UVCWebcamStream reference adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCV-based adapter for USB/UVC webcams. Background capture thread reads frames, timestamps each read with time.monotonic_ns(), emits a SampleEvent, and writes MP4 via cv2.VideoWriter. Declares produces_file=True and provides_audio_track=False (OpenCV has no audio path). Gated behind optional 'uvc' extra — raises a clear ImportError with the install hint when opencv-python is missing, so users know exactly how to resolve it. Internals are split into prepare/start/stop/_capture_loop plus small helper methods (_resolve_frame_geometry, _release_cv2_resources) so the lifecycle reads cleanly and the capture loop stays focused on its tight inner path. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/uvc_webcam.py | 173 +++++++++++++++++++++++++ tests/unit/adapters/test_uvc_webcam.py | 105 +++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/syncfield/adapters/uvc_webcam.py create mode 100644 tests/unit/adapters/test_uvc_webcam.py 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/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") From 1db274ce952c63be0c36b01dcfd30b644cd88b5a Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:15:11 -0700 Subject: [PATCH 19/23] feat(adapters): add BLEImuGenericStream reference adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic BLE IMU adapter using bleak. Bridges async bleak into the sync orchestrator by running an asyncio event loop on an internal background thread. Subscribes to a notify characteristic and decodes payloads with a configurable struct format and configurable channel names. - Connection/disconnection errors become ERROR health events - Payload decode errors (malformed notifications) become WARNING health events — never tear down the stream - Channel-name / format-length mismatches raise ValueError at construction - Payload decoding logic is split into _handle_payload so it is unit testable via a synchronous _dispatch_notification_for_test hook with no asyncio required - Gated behind optional 'ble' extra with clear install hint Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/ble_imu.py | 211 ++++++++++++++++++++++++++++ tests/unit/adapters/test_ble_imu.py | 124 ++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/syncfield/adapters/ble_imu.py create mode 100644 tests/unit/adapters/test_ble_imu.py 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/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=" Date: Thu, 9 Apr 2026 02:16:01 -0700 Subject: [PATCH 20/23] feat(api): rewrite public exports for new framework API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level exports: SessionOrchestrator, Stream, StreamBase, types, SyncToneConfig, ChirpSpec, SessionClock. Adapters subpackage always re-exports JSONLFileStream; UVC and BLE are re-exported lazily so an uninstalled extra does not break 'import syncfield.adapters'. Users needing a specific optional adapter can always import it directly from its module — that path raises a clear ImportError with an install hint when the dependency is missing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/__init__.py | 54 ++++++++++++++++++++++++++++-- src/syncfield/adapters/__init__.py | 41 +++++++++++++++++++++++ tests/unit/test_public_api.py | 37 ++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_public_api.py diff --git a/src/syncfield/__init__.py b/src/syncfield/__init__.py index a361e3d..01f9cba 100644 --- a/src/syncfield/__init__.py +++ b/src/syncfield/__init__.py @@ -1,9 +1,59 @@ """SyncField — capture orchestration framework for multi-modal synchronization. -This module's public API is being rebuilt. Imports will be added as tasks complete. +Quick start:: + + import syncfield as sf + from syncfield.adapters import UVCWebcamStream, JSONLFileStream + + 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")) + + session.start() + # ... recording ... + report = 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.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") -__all__: list[str] = [] diff --git a/src/syncfield/adapters/__init__.py b/src/syncfield/adapters/__init__.py index e69de29..c46b353 100644 --- a/src/syncfield/adapters/__init__.py +++ 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/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") From 18adb1c1e4c4e459b417ff5f5a37ff02788c0a2c Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:16:53 -0700 Subject: [PATCH 21/23] test: add round-trip integration test for end-to-end session Drives a two-stream session through the full lifecycle using FakeStream, asserts sync_point.json / manifest.json / session_log.jsonl match the schema the sync core expects. Covers: happy path with audio-capable stream, silent-mode session (no chirp fields), and audio-less single-host session (chirp skipped, session still completes). Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/integration/test_round_trip.py | 116 +++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/integration/test_round_trip.py 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" From 2e086a9c35f140e45b4c0febdb4dc5714bf8d1e6 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:17:53 -0700 Subject: [PATCH 22/23] docs(orchestrator): refresh module + stop() docstrings The module docstring and stop() docstring still referenced "added in subsequent tasks" from the incremental development. Replace them with accurate overviews that reflect the final file organization and the fully-integrated stop sequence including chirp playback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 306cb9d..2fba249 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -1,20 +1,25 @@ """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 manifest flushing, and +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. +coordination happens at the sync core when outputs from multiple hosts +are submitted together. -This skeleton is expanded incrementally — the ``start()``/``stop()`` logic, -chirp integration, crash-safe session log, and health routing are added in -subsequent tasks. +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. + 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 @@ -203,20 +208,21 @@ def stop(self) -> SessionReport: Sequence: 1. Validate state (must be ``RECORDING``) and transition to ``STOPPING``. - 2. For each stream, call ``stop()``. Exceptions become failed + 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. - 3. Write ``sync_point.json`` and ``manifest.json`` to the + 4. Write ``sync_point.json`` and ``manifest.json`` to the output directory. - 4. Transition to ``STOPPED`` and return the aggregated - :class:`SessionReport`. - - Chirp playback integration is added in a subsequent task; this - base implementation handles only the non-chirp finalization path. + 5. Transition to ``STOPPED``, close the session log, and + return the aggregated :class:`SessionReport`. Returns: Aggregated :class:`SessionReport` with per-stream - finalization reports. + finalization reports and chirp timestamps (if a chirp was + played). Raises: RuntimeError: If state is not ``RECORDING``. From 282bb0fe750b44b691c2dd90e2cb5c958847bba9 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 02:46:10 -0700 Subject: [PATCH 23/23] feat(tone): hand sounddevice a numpy buffer + add E2E chirp check script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sounddevice.play() internally requires numpy even when the caller passes a Python list, so SoundDeviceChirpPlayer now prepares a float32 ndarray explicitly. numpy is imported once at module load time (guarded by try/except) instead of inline inside play(), because doing it inline triggered numpy's C-extension one-time-init failure when unit tests patched sys.modules inside with-blocks. - pyproject.toml: syncfield[audio] now depends on numpy>=1.21 as well as sounddevice>=0.4.6. The [all] extra is kept in sync. - tone.py: module-level `try: import numpy as _np` once; play() uses _np to build the buffer, falling back to passing the raw list on machines that somehow have sounddevice but not numpy (real-world impossible, but the fallback keeps the contract honest). - scripts/e2e_chirp_check.py: standalone verification script that runs a real session against sounddevice on a headful machine. Checks: 1. create_default_player() picks SoundDeviceChirpPlayer 2. sync_point.json carries chirp_{start,stop}_ns and chirp_spec 3. stop_chirp_ns > start_chirp_ns with plausible spacing 4. (best-effort) a parallel sounddevice InputStream cross-correlates against the reference chirp — WARNs on quiet rooms rather than failing so the script stays usable in varied environments 5. audio-less sessions cleanly skip the chirp path Verified on macOS with MacBook Pro built-in speakers: all 5 checks pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 14 +- scripts/e2e_chirp_check.py | 265 +++++++++++++++++++++++++++++++++++++ src/syncfield/tone.py | 29 +++- uv.lock | 10 +- 4 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 scripts/e2e_chirp_check.py diff --git a/pyproject.toml b/pyproject.toml index 433f014..02e6bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,10 +24,20 @@ classifiers = [ ] [project.optional-dependencies] -audio = ["sounddevice>=0.4.6"] +# 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", "opencv-python>=4.5", "bleak>=0.21"] +all = [ + "sounddevice>=0.4.6", + "numpy>=1.21", + "opencv-python>=4.5", + "bleak>=0.21", +] [project.urls] Homepage = "https://opengraphlabs.com/" 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/tone.py b/src/syncfield/tone.py index 17b1089..b426792 100644 --- a/src/syncfield/tone.py +++ b/src/syncfield/tone.py @@ -21,13 +21,24 @@ import wave from dataclasses import dataclass, field from pathlib import Path -from typing import List, Protocol, runtime_checkable +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 # --------------------------------------------------------------------------- @@ -251,12 +262,22 @@ def __init__(self, sample_rate: int = 44100) -> None: self._sample_rate = sample_rate def play(self, spec: ChirpSpec) -> None: - # Lazy import keeps the module importable on machines that lack - # sounddevice — only instantiating this class requires the dep. + # 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) - sd.play(samples, self._sample_rate) # non-blocking: returns immediately + 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 diff --git a/uv.lock b/uv.lock index 028d9e2..ee625cd 100644 --- a/uv.lock +++ b/uv.lock @@ -264,7 +264,7 @@ 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 = [ @@ -825,10 +825,16 @@ source = { editable = "." } 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 = [ @@ -850,6 +856,8 @@ dev = [ 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" },