From 7a9e67ac1c3b46253588842bd4729a00f681c256 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 8 Apr 2026 13:33:37 -0600 Subject: [PATCH] =?UTF-8?q?feat!:=20v0.2.0=20=E2=80=94=20module=20split,?= =?UTF-8?q?=20Rust=20parity,=20PyPI-ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split monolithic __init__.py into six modules matching the Rust client structure: protocol.py, codec.py, discovery.py, transport.py, connect.py, client.py. All symbols re-exported from top-level for backwards compat. New features: - Ping-on-connect validation (200ms handshake, matches Rust v0.2.0) - try_connect(), connect_default(), connect_mux_auto(), validate() - connect() commits to mux when socket exists (no USB fall-through) - Bandwidth/ErrorCode IntEnums, RadioConfig TypedDict, py.typed marker - MAX_PAYLOAD, RADIO_CONFIG_SIZE, tag/error constants - Payload size validation on Transmit - 46 pytest tests ported from Rust (protocol, codec, client, firmware compat) - GitHub Actions CI (ruff + pytest on 3.10-3.13 + PyPI publish) - Strict ruff linting (E, W, F, I, UP, B, C4, ARG, SIM, RUF) Breaking: package renamed back to donglora (from donglora-python). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 42 ++++ .gitignore | 1 + CHANGELOG.md | 53 +++++ README.md | 51 ++++- donglora/__init__.py | 143 ++++++++++++++ donglora/client.py | 108 ++++++++++ donglora/codec.py | 44 +++++ donglora/connect.py | 213 ++++++++++++++++++++ donglora/discovery.py | 65 ++++++ donglora/protocol.py | 234 ++++++++++++++++++++++ donglora/py.typed | 0 donglora/transport.py | 50 +++++ donglora_python/__init__.py | 381 ------------------------------------ justfile | 8 +- pyproject.toml | 35 +++- tests/test_client.py | 135 +++++++++++++ tests/test_codec.py | 75 +++++++ tests/test_protocol.py | 254 ++++++++++++++++++++++++ uv.lock | 290 ++++++++++++++++++++++++++- 19 files changed, 1787 insertions(+), 395 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 donglora/__init__.py create mode 100644 donglora/client.py create mode 100644 donglora/codec.py create mode 100644 donglora/connect.py create mode 100644 donglora/discovery.py create mode 100644 donglora/protocol.py create mode 100644 donglora/py.typed create mode 100644 donglora/transport.py delete mode 100644 donglora_python/__init__.py create mode 100644 tests/test_client.py create mode 100644 tests/test_codec.py create mode 100644 tests/test_protocol.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2e00532 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv run ruff check . + - run: uv run ruff format --check . + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - run: uv sync + - run: uv run pytest -v --cov=donglora --cov-report=term-missing + + publish: + needs: [lint, test] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv build + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 21d0b89..a230a78 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .venv/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..716b106 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +## 0.2.0 — 2026-04-08 + +### Features + +- **Module split matching Rust structure** — split monolithic `__init__.py` into + `protocol.py`, `codec.py`, `discovery.py`, `transport.py`, `connect.py`, and + `client.py`. All symbols re-exported from top-level for backwards compatibility. +- **Ping-on-connect validation** — all connect functions automatically ping the + device and reject non-DongLoRa devices within 200 ms (matches Rust v0.2.0). +- **`try_connect(timeout)`** — non-blocking alternative to `connect()`. Returns + an error immediately if no device is found. +- **`connect_default()`** — convenience wrapper with default timeout. +- **`connect_mux_auto(timeout)`** — mux-only connection, never falls back to USB + serial. Equivalent of Rust client's "sticky mux" behaviour. +- **`default_socket_path()`** — resolve the preferred mux socket path. +- **`validate(ser)`** — public function for manual device validation. +- **Payload size validation** — `encode_command("Transmit", ...)` rejects + payloads exceeding 256 bytes. +- **`Bandwidth` and `ErrorCode` enums** — `IntEnum` types matching the Rust + definitions. +- **`RadioConfig` TypedDict** — typed dict for radio configuration. +- **New constants** — `MAX_PAYLOAD`, `RADIO_CONFIG_SIZE`, `CMD_TAG_*`, + `RESP_TAG_*`, `ERROR_*` tag/error constants. +- **`py.typed` marker** — PEP 561 support for type checkers. +- **Comprehensive test suite** — 46 tests ported from the Rust client covering + protocol roundtrips, COBS framing, client send/recv, firmware wire + compatibility. +- **GitHub Actions CI** — ruff lint + format, pytest on Python 3.10–3.13, PyPI + publish on tag. +- **Strict ruff linting** — E, W, F, I, UP, B, C4, ARG, SIM, RUF rules. + +### Breaking changes + +- **Package renamed back to `donglora`** — the short-lived `donglora-python` + name is reverted. Import as `import donglora` (was always the intended API). +- **`connect()` no longer falls through from mux to USB serial** — if a mux + socket file exists, the function commits to the mux or raises an error + (matches Rust v0.2.1). This prevents port-stealing race conditions. +- **Sticky mux global removed** — the module-level `_mux_mode` variable and + `_reconnect_mux()` are gone. Use `connect_mux_auto()` for mux-only mode. +- **`DEFAULT_CONFIG` now includes `cad: 1`** — previously relied on + `encode_config()` defaulting it. + +### Fixes + +- Fixed broken dependency paths that prevented `uv sync` from resolving. +- Replaced `print()` calls with `logging` in discovery module. + +## 0.1.0 — 2026-04-06 + +Initial release. diff --git a/README.md b/README.md index 77756c2..4939dd6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ # DongLoRa Python Client Python client library for talking to a DongLoRa device — either directly -over USB or through the [mux daemon](../../mux/). +over USB or through the [mux daemon](https://github.com/donglora/mux-py). -## What's in Here +## Install -- `donglora/` — the library: device discovery, COBS framing, protocol - encoding/decoding, mux client support -- Installable as `donglora` via pip/uv +``` +pip install donglora +``` -## Usage +Or with [uv](https://docs.astral.sh/uv/): + +``` +uv add donglora +``` + +## Quick Start ```python import donglora as dl @@ -17,15 +23,48 @@ import donglora as dl ser = dl.connect() dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG) dl.send(ser, "StartRx") + while True: pkt = dl.recv(ser) if pkt: print(pkt["rssi"], pkt["payload"].hex()) ``` +## Connection Functions + +| Function | Description | +|----------|-------------| +| `connect()` | Auto-detect: TCP mux, Unix socket mux, then USB serial | +| `connect_default()` | Convenience wrapper with default timeout | +| `try_connect()` | Like `connect()` but non-blocking — raises if no device found | +| `connect_mux_auto()` | Mux only — never falls back to USB serial | +| `mux_connect()` | Connect via Unix domain socket | +| `mux_tcp_connect()` | Connect via TCP | + +## Module Structure + +| Module | Contents | +|--------|----------| +| `donglora.protocol` | Wire types, RadioConfig, encode/decode, constants | +| `donglora.codec` | COBS framing | +| `donglora.discovery` | USB device discovery by VID:PID | +| `donglora.transport` | MuxConnection (socket wrapper) | +| `donglora.connect` | Connection auto-detection and mux helpers | +| `donglora.client` | High-level send/recv/validate | + +Everything is re-exported from the top-level `donglora` package. + ## Dependencies - `cobs` — COBS framing - `pyserial` — USB serial communication Optional extras: `meshcore` (crypto), `orac` (AI bot). + +## Development + +``` +just check # fmt + lint + test +just fmt # format code +just test # run tests +``` diff --git a/donglora/__init__.py b/donglora/__init__.py new file mode 100644 index 0000000..aebb608 --- /dev/null +++ b/donglora/__init__.py @@ -0,0 +1,143 @@ +"""DongLoRa host library — connect, configure, send/receive LoRa packets. + +Implements the DongLoRa USB protocol (COBS-framed fixed-size LE). +See firmware/PROTOCOL.md for the full specification. + +Quick start:: + + import donglora as dl + + ser = dl.connect() + dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG) + dl.send(ser, "StartRx") + + while True: + pkt = dl.recv(ser) + if pkt: + print(pkt["rssi"], pkt["payload"].hex()) +""" + +from donglora.client import ( + HANDSHAKE_TIMEOUT, + drain_rx, + recv, + send, + validate, +) +from donglora.codec import cobs_encode, read_frame +from donglora.connect import ( + DEFAULT_TIMEOUT, + connect, + connect_default, + connect_mux_auto, + default_socket_path, + mux_connect, + mux_tcp_connect, + try_connect, +) +from donglora.discovery import ( + BRIDGE_VID_PIDS, + USB_PID, + USB_VID, + USB_VID_PID, + find_port, + wait_for_device, +) +from donglora.protocol import ( + CMD_TAG_GET_CONFIG, + CMD_TAG_GET_MAC, + CMD_TAG_PING, + CMD_TAG_SET_CONFIG, + CMD_TAG_START_RX, + CMD_TAG_STOP_RX, + CMD_TAG_TRANSMIT, + DEFAULT_CONFIG, + ERROR_INVALID_CONFIG, + ERROR_NO_DISPLAY, + ERROR_NOT_CONFIGURED, + ERROR_RADIO_BUSY, + ERROR_TX_TIMEOUT, + MAX_PAYLOAD, + PREAMBLE_DEFAULT, + RADIO_CONFIG_SIZE, + RESP_TAG_CONFIG, + RESP_TAG_ERROR, + RESP_TAG_MAC_ADDRESS, + RESP_TAG_OK, + RESP_TAG_PONG, + RESP_TAG_RX_PACKET, + RESP_TAG_TX_DONE, + TX_POWER_MAX, + Bandwidth, + ErrorCode, + RadioConfig, + decode_config, + decode_response, + encode_command, + encode_config, +) +from donglora.transport import MuxConnection + +__all__ = [ + "BRIDGE_VID_PIDS", + "CMD_TAG_GET_CONFIG", + "CMD_TAG_GET_MAC", + # tag constants + "CMD_TAG_PING", + "CMD_TAG_SET_CONFIG", + "CMD_TAG_START_RX", + "CMD_TAG_STOP_RX", + "CMD_TAG_TRANSMIT", + "DEFAULT_CONFIG", + # connect + "DEFAULT_TIMEOUT", + # error constants + "ERROR_INVALID_CONFIG", + "ERROR_NOT_CONFIGURED", + "ERROR_NO_DISPLAY", + "ERROR_RADIO_BUSY", + "ERROR_TX_TIMEOUT", + # client + "HANDSHAKE_TIMEOUT", + # protocol + "MAX_PAYLOAD", + "PREAMBLE_DEFAULT", + "RADIO_CONFIG_SIZE", + "RESP_TAG_CONFIG", + "RESP_TAG_ERROR", + "RESP_TAG_MAC_ADDRESS", + "RESP_TAG_OK", + "RESP_TAG_PONG", + "RESP_TAG_RX_PACKET", + "RESP_TAG_TX_DONE", + "TX_POWER_MAX", + "USB_PID", + # discovery + "USB_VID", + "USB_VID_PID", + "Bandwidth", + "ErrorCode", + # transport + "MuxConnection", + "RadioConfig", + # codec + "cobs_encode", + "connect", + "connect_default", + "connect_mux_auto", + "decode_config", + "decode_response", + "default_socket_path", + "drain_rx", + "encode_command", + "encode_config", + "find_port", + "mux_connect", + "mux_tcp_connect", + "read_frame", + "recv", + "send", + "try_connect", + "validate", + "wait_for_device", +] diff --git a/donglora/client.py b/donglora/client.py new file mode 100644 index 0000000..2f14eee --- /dev/null +++ b/donglora/client.py @@ -0,0 +1,108 @@ +"""High-level DongLoRa client helpers. + +Mirrors the Rust client's ``client.rs``. Provides :func:`send`, +:func:`recv`, :func:`drain_rx`, and :func:`validate` as module-level +functions that operate on any transport (serial port or mux connection). +""" + +from __future__ import annotations + +import collections +from typing import Any + +from donglora.codec import cobs_encode, read_frame +from donglora.protocol import decode_response, encode_command + +RX_BUFFER_CAP: int = 256 +"""Maximum number of buffered RxPackets before oldest are dropped.""" + +MAX_UNSOLICITED_BEFORE_TIMEOUT: int = 50 +"""Safety bound: max frames to read while waiting for a solicited response.""" + +HANDSHAKE_TIMEOUT: float = 0.2 +"""Timeout (seconds) for the ping-on-connect handshake.""" + +_rx_queue: collections.deque[dict[str, Any]] = collections.deque(maxlen=RX_BUFFER_CAP) +"""Buffer for RxPacket frames encountered while waiting for solicited responses.""" + + +def validate(ser: Any) -> None: + """Ping the device and verify the Pong response. + + Uses a short timeout to fail fast on non-DongLoRa devices. Called + automatically by the ``connect`` family of functions. + + Raises :class:`ConnectionError` if the device does not respond. + """ + saved = ser.timeout + ser.timeout = HANDSHAKE_TIMEOUT + try: + resp = send(ser, "Ping") + if resp["type"] != "Pong": + raise ConnectionError(f"device did not respond to ping — got {resp['type']}") + except ConnectionError: + raise + except Exception as exc: + raise ConnectionError("device did not respond to ping — not a DongLoRa device") from exc + finally: + ser.timeout = saved + + +def send(ser: Any, cmd: str, **kwargs: Any) -> dict[str, Any]: + """Send a command and return the solicited response. + + Any unsolicited ``RxPacket`` frames encountered while waiting are + buffered in the module-level receive queue (retrievable via + :func:`recv` / :func:`drain_rx`). + """ + ser.write(cobs_encode(encode_command(cmd, **kwargs))) + ser.flush() + for _ in range(MAX_UNSOLICITED_BEFORE_TIMEOUT): + data = read_frame(ser) + if data is None: + return {"type": "Timeout"} + resp = decode_response(data) + if resp["type"] == "RxPacket": + _rx_queue.append(resp) + continue + return resp + return {"type": "Timeout"} + + +def recv(ser: Any) -> dict[str, Any] | None: + """Return the next RxPacket from the buffer or the wire. + + Returns ``None`` on timeout (no packet available). + """ + if _rx_queue: + return _rx_queue.popleft() + data = read_frame(ser) + if data is None: + return None + resp = decode_response(data) + if resp["type"] == "RxPacket": + return resp + return None + + +def drain_rx(ser: Any) -> list[dict[str, Any]]: + """Drain all buffered and pending RxPacket frames. + + Temporarily reduces the read timeout to quickly drain any frames + still in flight, then restores the original timeout. + """ + packets: list[dict[str, Any]] = list(_rx_queue) + _rx_queue.clear() + old_timeout = ser.timeout + ser.timeout = 0.01 + try: + while True: + data = read_frame(ser) + if data is None: + break + resp = decode_response(data) + if resp["type"] == "RxPacket": + packets.append(resp) + finally: + ser.timeout = old_timeout + return packets diff --git a/donglora/codec.py b/donglora/codec.py new file mode 100644 index 0000000..169e273 --- /dev/null +++ b/donglora/codec.py @@ -0,0 +1,44 @@ +"""COBS framing for the DongLoRa USB protocol. + +Mirrors the Rust client's ``codec.rs``. Frames are COBS-encoded with a +``0x00`` sentinel byte. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from cobs import cobs + + +@runtime_checkable +class Readable(Protocol): + """Anything with a blocking ``read(n)`` method (serial port, socket wrapper, etc.).""" + + def read(self, n: int = 1) -> bytes: ... + + +def cobs_encode(data: bytes) -> bytes: + """COBS-encode *data* and append the ``0x00`` sentinel.""" + return cobs.encode(data) + b"\x00" + + +def read_frame(reader: Readable) -> bytes | None: + """Read one COBS frame from *reader*. + + Returns decoded payload bytes, or ``None`` on timeout (zero-length read). + """ + buf = b"" + while True: + b = reader.read(1) + if not b: + return None + if b == b"\x00": + break + buf += b + if not buf: + return None + try: + return cobs.decode(buf) + except cobs.DecodeError: + return None diff --git a/donglora/connect.py b/donglora/connect.py new file mode 100644 index 0000000..98d29d2 --- /dev/null +++ b/donglora/connect.py @@ -0,0 +1,213 @@ +"""Connection auto-detection and mux client helpers. + +Mirrors the Rust client's ``connect.rs``. The :func:`connect` function +tries mux connections first (TCP via env var, then Unix socket), falling +back to direct USB serial. This matches the Rust client's ``connect()`` +behaviour. +""" + +from __future__ import annotations + +import logging +import os +import socket +from typing import Any + +import serial + +from donglora.client import validate +from donglora.discovery import find_port, wait_for_device +from donglora.transport import MuxConnection + +log = logging.getLogger("donglora") + +DEFAULT_TIMEOUT: float = 2.0 +"""Default read timeout for connections (seconds).""" + + +def default_socket_path() -> str: + """Resolve the mux socket path in priority order. + + 1. ``$DONGLORA_MUX`` environment variable + 2. ``$XDG_RUNTIME_DIR/donglora/mux.sock`` + 3. ``/tmp/donglora-mux.sock`` + """ + env = os.environ.get("DONGLORA_MUX") + if env: + return env + xdg = os.environ.get("XDG_RUNTIME_DIR") + if xdg: + return os.path.join(xdg, "donglora", "mux.sock") + return "/tmp/donglora-mux.sock" + + +def _find_mux_socket() -> str | None: + """Find an existing mux socket path, or ``None`` if no socket file exists.""" + env = os.environ.get("DONGLORA_MUX") + if env: + return env if os.path.exists(env) else None + xdg = os.environ.get("XDG_RUNTIME_DIR") + if xdg: + p = os.path.join(xdg, "donglora", "mux.sock") + if os.path.exists(p): + return p + p = "/tmp/donglora-mux.sock" + return p if os.path.exists(p) else None + + +def _try_tcp_mux(addr: str, timeout: float) -> MuxConnection | None: + """Try to connect to a TCP mux at *addr* (``[host:]port``). + + Returns ``None`` on failure instead of raising. + """ + if ":" in addr: + host, _, port_str = addr.rpartition(":") + host = host or "localhost" + else: + host = "localhost" + port_str = addr + try: + port = int(port_str) + except ValueError: + return None + try: + return mux_tcp_connect(host, port, timeout) + except (ConnectionRefusedError, OSError): + return None + + +# ── Public connection functions ─────────────────────────────────── + + +def mux_connect(path: str | None = None, timeout: float = DEFAULT_TIMEOUT) -> MuxConnection: + """Connect to the DongLoRa mux daemon via Unix domain socket.""" + if path is None: + path = _find_mux_socket() + if path is None: + raise FileNotFoundError("no mux socket found") + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(path) + conn = MuxConnection(sock, timeout) + validate(conn) + return conn + + +def mux_tcp_connect(host: str, port: int, timeout: float = DEFAULT_TIMEOUT) -> MuxConnection: + """Connect to the DongLoRa mux daemon via TCP.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + conn = MuxConnection(sock, timeout) + validate(conn) + return conn + + +def connect(port: str | None = None, timeout: float = DEFAULT_TIMEOUT) -> Any: + """Auto-detect and connect to a DongLoRa device. + + Priority: + + 1. ``DONGLORA_MUX_TCP`` env var -> TCP mux connection + 2. Unix socket mux (if socket file exists) + 3. Direct USB serial (auto-detect by VID:PID, blocks until device appears) + + If *port* is given, skips mux detection and connects directly. + + .. note:: + + If a mux socket file exists, this function **commits** to the mux + and will raise on failure rather than falling through to USB serial. + This prevents port-stealing race conditions (matches Rust v0.2.1). + """ + # Explicit port — go direct + if port is not None: + log.debug("opening serial port %s", port) + ser = serial.Serial(port, baudrate=115200, timeout=timeout) + ser.reset_input_buffer() + validate(ser) + return ser + + # Try TCP mux via environment variable + tcp = os.environ.get("DONGLORA_MUX_TCP") + if tcp: + conn = _try_tcp_mux(tcp, timeout) + if conn is not None: + log.debug("connected to TCP mux at %s", tcp) + return conn + + # Try Unix socket mux — commit if socket exists (no fall-through) + sock_path = _find_mux_socket() + if sock_path is not None: + log.debug("mux socket found at %s — connecting via mux only", sock_path) + return mux_connect(sock_path, timeout) + + # Direct USB serial — only reached when no mux socket exists at all + port_path = find_port() + if port_path is None: + port_path = wait_for_device() + log.debug("opening serial port %s", port_path) + ser = serial.Serial(port_path, baudrate=115200, timeout=timeout) + ser.reset_input_buffer() + validate(ser) + return ser + + +def connect_default() -> Any: + """Connect with the default timeout. Convenience wrapper for ``connect(None)``.""" + return connect(timeout=DEFAULT_TIMEOUT) + + +def connect_mux_auto(timeout: float = DEFAULT_TIMEOUT) -> Any: + """Connect to a mux daemon only (TCP via env var, then Unix socket). + + Unlike :func:`connect`, this **never** falls back to direct USB serial. + Returns an error if no mux is reachable — the caller can retry with + backoff. + + This is the Python equivalent of the Rust client's "sticky mux" behaviour: + once you decide to use the mux, you stay on the mux. + """ + # Try TCP mux via environment variable + tcp = os.environ.get("DONGLORA_MUX_TCP") + if tcp: + conn = _try_tcp_mux(tcp, timeout) + if conn is not None: + log.debug("connected to TCP mux at %s", tcp) + return conn + + # Try Unix socket mux + sock_path = _find_mux_socket() + if sock_path is None: + raise FileNotFoundError("no mux socket found") + return mux_connect(sock_path, timeout) + + +def try_connect(timeout: float = DEFAULT_TIMEOUT) -> Any: + """Like :func:`connect` but non-blocking when no USB device is present. + + Runs the same fallback chain (TCP mux, Unix socket mux, direct USB + serial) but uses a single non-blocking scan instead of polling + indefinitely for a USB device. Raises if nothing is found. + """ + # Try TCP mux via environment variable + tcp = os.environ.get("DONGLORA_MUX_TCP") + if tcp: + conn = _try_tcp_mux(tcp, timeout) + if conn is not None: + log.debug("connected to TCP mux at %s", tcp) + return conn + + # Try Unix socket mux — commit if socket exists + sock_path = _find_mux_socket() + if sock_path is not None: + log.debug("mux socket found at %s — connecting via mux only", sock_path) + return mux_connect(sock_path, timeout) + + # Direct USB serial — single non-blocking scan + port_path = find_port() + if port_path is None: + raise FileNotFoundError("no DongLoRa device found (no mux, no USB device)") + log.debug("opening serial port %s", port_path) + ser = serial.Serial(port_path, baudrate=115200, timeout=timeout) + ser.reset_input_buffer() + validate(ser) + return ser diff --git a/donglora/discovery.py b/donglora/discovery.py new file mode 100644 index 0000000..80b5e0e --- /dev/null +++ b/donglora/discovery.py @@ -0,0 +1,65 @@ +"""USB device discovery for DongLoRa dongles. + +Mirrors the Rust client's ``discovery.rs``. Scans ``/dev/ttyACM*`` and +``/dev/ttyUSB*`` by VID:PID, preferring native USB CDC-ACM over known +USB-UART bridge chips. +""" + +from __future__ import annotations + +import glob +import logging +import subprocess +import time + +log = logging.getLogger("donglora") + +USB_VID: int = 0x1209 +USB_PID: int = 0x5741 +USB_VID_PID: str = "1209:5741" + +BRIDGE_VID_PIDS: set[str] = {"10c4:ea60", "1a86:55d4", "1a86:7523", "0403:6001"} +"""Known USB-UART bridge VID:PIDs found on some board revisions.""" + + +def find_port() -> str | None: + """Find the DongLoRa serial port by USB VID:PID. + + Checks for native USB CDC-ACM first (1209:5741), then falls back to + known USB-UART bridge chips found on some board revisions. + """ + bridge_match: str | None = None + for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")): + try: + result = subprocess.run( + ["udevadm", "info", "--query=property", f"--name={path}"], + capture_output=True, + text=True, + timeout=2, + ) + props = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + vid = props.get("ID_VENDOR_ID", "").lower() + pid = props.get("ID_MODEL_ID", "").lower() + vid_pid = f"{vid}:{pid}" + if vid_pid == USB_VID_PID: + return path + if bridge_match is None and vid_pid in BRIDGE_VID_PIDS: + bridge_match = path + except Exception: + continue + if bridge_match is not None: + return bridge_match + ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")) + return ports[0] if ports else None + + +def wait_for_device() -> str: + """Block until a DongLoRa device appears, polling every 500 ms.""" + log.info("waiting for DongLoRa...") + while True: + port = find_port() + if port: + log.info("found %s", port) + time.sleep(0.3) + return port + time.sleep(0.5) diff --git a/donglora/protocol.py b/donglora/protocol.py new file mode 100644 index 0000000..1d765a1 --- /dev/null +++ b/donglora/protocol.py @@ -0,0 +1,234 @@ +"""Wire protocol types and fixed-size little-endian serialization. + +Mirrors the firmware's protocol and the Rust client's ``protocol.rs`` +using Python types. Every integer is fixed-width LE. +""" + +from __future__ import annotations + +import enum +import struct +from typing import Any, TypedDict + +# ── Size constants ──────────────────────────────────────────────── + +MAX_PAYLOAD: int = 256 +"""Maximum LoRa payload size in bytes.""" + +RADIO_CONFIG_SIZE: int = 13 +"""RadioConfig wire size (fixed).""" + +TX_POWER_MAX: int = -128 +"""Sentinel: set TX power to the board's maximum (i8::MIN on the wire).""" + +PREAMBLE_DEFAULT: int = 0 +"""Sentinel: use firmware default preamble length (16 symbols).""" + +# ── Tag constants ───────────────────────────────────────────────── + +CMD_TAG_PING: int = 0 +CMD_TAG_GET_CONFIG: int = 1 +CMD_TAG_SET_CONFIG: int = 2 +CMD_TAG_START_RX: int = 3 +CMD_TAG_STOP_RX: int = 4 +CMD_TAG_TRANSMIT: int = 5 +CMD_TAG_DISPLAY_ON: int = 6 +CMD_TAG_DISPLAY_OFF: int = 7 +CMD_TAG_GET_MAC: int = 8 + +RESP_TAG_PONG: int = 0 +RESP_TAG_CONFIG: int = 1 +RESP_TAG_RX_PACKET: int = 2 +RESP_TAG_TX_DONE: int = 3 +RESP_TAG_OK: int = 4 +RESP_TAG_ERROR: int = 5 +RESP_TAG_MAC_ADDRESS: int = 6 + +# ── Enums ───────────────────────────────────────────────────────── + + +class Bandwidth(enum.IntEnum): + """LoRa signal bandwidth.""" + + Khz7 = 0 + Khz10 = 1 + Khz15 = 2 + Khz20 = 3 + Khz31 = 4 + Khz41 = 5 + Khz62 = 6 + Khz125 = 7 + Khz250 = 8 + Khz500 = 9 + + +class ErrorCode(enum.IntEnum): + """Error codes reported by the firmware.""" + + InvalidConfig = 0 + RadioBusy = 1 + TxTimeout = 2 + CrcError = 3 + NotConfigured = 4 + NoDisplay = 5 + + +ERROR_INVALID_CONFIG: int = ErrorCode.InvalidConfig +ERROR_RADIO_BUSY: int = ErrorCode.RadioBusy +ERROR_TX_TIMEOUT: int = ErrorCode.TxTimeout +ERROR_NOT_CONFIGURED: int = ErrorCode.NotConfigured +ERROR_NO_DISPLAY: int = ErrorCode.NoDisplay + +# ── RadioConfig ─────────────────────────────────────────────────── + + +class RadioConfig(TypedDict): + """Complete LoRa radio configuration. + + Wire layout (13 bytes, all little-endian):: + + [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] + [tx_power_dbm:1] [preamble_len:2] [cad:1] + """ + + freq_hz: int + bw: int + sf: int + cr: int + sync_word: int + tx_power_dbm: int + preamble_len: int + cad: int + + +DEFAULT_CONFIG: RadioConfig = { + "freq_hz": 915_000_000, + "bw": Bandwidth.Khz125, + "sf": 7, + "cr": 5, + "sync_word": 0x1424, + "tx_power_dbm": TX_POWER_MAX, + "preamble_len": PREAMBLE_DEFAULT, + "cad": 1, +} +"""Default radio config: 915 MHz, 125 kHz BW, SF7, CR 4/5, max power, CAD on.""" + +# ── Config wire encoding ────────────────────────────────────────── + +_CONFIG_STRUCT = struct.Struct(" bytes: + """Encode a RadioConfig to 13 fixed-size LE bytes.""" + return _CONFIG_STRUCT.pack( + cfg["freq_hz"], + cfg["bw"], + cfg["sf"], + cfg["cr"], + cfg["sync_word"], + cfg["tx_power_dbm"] & 0xFF, + cfg.get("preamble_len", PREAMBLE_DEFAULT), + cfg.get("cad", 1), + ) + + +def decode_config(data: bytes) -> RadioConfig | None: + """Decode a RadioConfig from 13 LE bytes. Returns ``None`` if too short or invalid.""" + if len(data) < RADIO_CONFIG_SIZE: + return None + freq_hz, bw_raw, sf, cr, sync_word, _pwr_unsigned, preamble_len, cad = ( + _CONFIG_STRUCT.unpack_from(data, 0) + ) + try: + Bandwidth(bw_raw) + except ValueError: + return None + tx_power_dbm = struct.unpack_from(" bytes: + """Encode a command to fixed-size LE bytes. + + Raises ``ValueError`` for unknown commands or oversized payloads. + """ + tag = _COMMAND_TAGS.get(cmd) + if tag is None: + raise ValueError(f"unknown command: {cmd!r}") + out = bytes([tag]) + if cmd == "SetConfig": + out += encode_config(kwargs["config"]) + elif cmd == "Transmit": + config: RadioConfig | None = kwargs.get("config") + if config is None: + out += b"\x00" + else: + out += b"\x01" + encode_config(config) + payload: bytes = kwargs["payload"] + if len(payload) > MAX_PAYLOAD: + raise ValueError(f"payload too large ({len(payload)} bytes, max {MAX_PAYLOAD})") + out += struct.pack(" dict[str, Any]: + """Decode a fixed-size LE response into a dict.""" + if not data: + return {"type": "Empty"} + tag = data[0] + rest = data[1:] + if tag == RESP_TAG_PONG: + return {"type": "Pong"} + if tag == RESP_TAG_CONFIG: + cfg = decode_config(rest) + if cfg is not None: + return {"type": "Config", **cfg} + return {"type": "Config", "raw": rest.hex()} + if tag == RESP_TAG_RX_PACKET: + if len(rest) < 6: + return {"type": "RxPacket", "rssi": 0, "snr": 0, "payload": b""} + rssi = struct.unpack_from("= 6: + mac = ":".join(f"{b:02X}" for b in rest[:6]) + return {"type": "MacAddress", "mac": mac} + return {"type": "MacAddress", "raw": rest.hex()} + return {"type": f"Unknown({tag})"} diff --git a/donglora/py.typed b/donglora/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/donglora/transport.py b/donglora/transport.py new file mode 100644 index 0000000..65f5ae3 --- /dev/null +++ b/donglora/transport.py @@ -0,0 +1,50 @@ +"""Transport abstractions for serial and mux connections. + +Mirrors the Rust client's ``transport.rs``. The :class:`MuxConnection` +class is a drop-in replacement for ``serial.Serial`` that talks to the +mux daemon over a Unix domain socket or TCP. +""" + +from __future__ import annotations + +import socket + + +class MuxConnection: + """Drop-in replacement for ``serial.Serial`` that talks to the mux daemon.""" + + def __init__(self, sock: socket.socket, timeout: float = 2.0) -> None: + self._sock = sock + self._timeout = timeout + self._sock.settimeout(timeout) + + @property + def timeout(self) -> float: + return self._timeout + + @timeout.setter + def timeout(self, value: float) -> None: + self._timeout = value + self._sock.settimeout(value) + + def read(self, n: int = 1) -> bytes: + try: + data = self._sock.recv(n) + if not data: + raise ConnectionError("mux disconnected") + return data + except TimeoutError: + return b"" + + def write(self, data: bytes) -> int: + self._sock.sendall(data) + return len(data) + + def flush(self) -> None: + pass + + def reset_input_buffer(self) -> None: + pass + + def close(self) -> None: + self._sock.close() diff --git a/donglora_python/__init__.py b/donglora_python/__init__.py deleted file mode 100644 index bb58efa..0000000 --- a/donglora_python/__init__.py +++ /dev/null @@ -1,381 +0,0 @@ -"""DongLoRa host library — connect, configure, send/receive LoRa packets. - -Implements the DongLoRa USB protocol (COBS-framed fixed-size LE). -See firmware/PROTOCOL.md for the full specification. -""" - -import collections -import glob -import os -import socket -import struct -import subprocess - -import serial -import time -from cobs import cobs - -# ── USB device discovery ────────────────────────────────────────── - -USB_VID_PID = "1209:5741" - -# Known USB-UART bridge VID:PIDs found on some board revisions. -BRIDGE_VID_PIDS = {"10c4:ea60", "1a86:55d4", "1a86:7523", "0403:6001"} - - -def find_port() -> str | None: - """Find the DongLoRa serial port by USB VID:PID. - - Checks for native USB CDC-ACM first (1209:5741), then falls back to - known USB-UART bridge chips found on some board revisions. - """ - bridge_match = None - for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")): - try: - result = subprocess.run( - ["udevadm", "info", "--query=property", f"--name={path}"], - capture_output=True, text=True, timeout=2, - ) - props = dict( - line.split("=", 1) for line in result.stdout.splitlines() if "=" in line - ) - vid = props.get("ID_VENDOR_ID", "").lower() - pid = props.get("ID_MODEL_ID", "").lower() - vid_pid = f"{vid}:{pid}" - if vid_pid == USB_VID_PID: - return path - if bridge_match is None and vid_pid in BRIDGE_VID_PIDS: - bridge_match = path - except Exception: - continue - if bridge_match is not None: - return bridge_match - ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")) - return ports[0] if ports else None - - -def wait_for_device() -> str: - """Block until a DongLoRa device appears.""" - print("Waiting for DongLoRa...", end="", flush=True) - while True: - port = find_port() - if port: - print(f" found {port}") - time.sleep(0.3) - return port - print(".", end="", flush=True) - time.sleep(0.5) - - -# ── COBS framing ───────────────────────────────────────────────── - -def cobs_encode(data: bytes) -> bytes: - return cobs.encode(data) + b"\x00" - - -def read_frame(ser: serial.Serial) -> bytes | None: - """Read one COBS frame. Returns decoded bytes or None on timeout.""" - buf = b"" - while True: - b = ser.read(1) - if not b: - return None - if b == b"\x00": - break - buf += b - if not buf: - return None - try: - return cobs.decode(buf) - except cobs.DecodeError: - return None - - -# ── Fixed-size LE encoding ──────────────────────────────────────── - -def encode_config(cfg: dict) -> bytes: - """Encode a RadioConfig to 13 fixed-size LE bytes.""" - return struct.pack(" bytes: - """Encode a command to fixed-size LE bytes.""" - tags = { - "Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3, - "StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7, - "GetMac": 8, - } - out = bytes([tags[cmd]]) - if cmd == "SetConfig": - out += encode_config(kwargs["config"]) - elif cmd == "Transmit": - config = kwargs.get("config") - if config is None: - out += b"\x00" - else: - out += b"\x01" + encode_config(config) - payload = kwargs["payload"] - out += struct.pack(" dict: - """Decode a fixed-size LE response.""" - if not data: - return {"type": "Empty"} - tag = data[0] - rest = data[1:] - if tag == 0: - return {"type": "Pong"} - elif tag == 1: - if len(rest) >= 13: - freq_hz, bw, sf, cr, sync_word, pwr, preamble_len, cad = struct.unpack_from("= 6: - mac = ":".join(f"{b:02X}" for b in rest[:6]) - return {"type": "MacAddress", "mac": mac} - return {"type": "MacAddress", "raw": rest.hex()} - else: - return {"type": f"Unknown({tag})"} - - -# ── High-level helpers ─────────────────────────────────────────── - -# Buffer for RxPacket frames encountered while waiting for solicited responses. -# These are real received LoRa data — don't discard them. -_rx_queue: collections.deque = collections.deque(maxlen=256) - - -def send(ser: serial.Serial, cmd: str, **kwargs) -> dict: - """Send a command and return the solicited response. - - Any unsolicited RxPacket frames encountered while waiting are buffered - in the module-level receive queue (retrievable via recv/drain_rx). - """ - ser.write(cobs_encode(encode_command(cmd, **kwargs))) - ser.flush() - for _ in range(50): # safety bound — don't loop forever - data = read_frame(ser) - if data is None: - return {"type": "Timeout"} - resp = decode_response(data) - if resp["type"] == "RxPacket": - _rx_queue.append(resp) - continue - return resp - return {"type": "Timeout"} - - -def recv(ser: serial.Serial) -> dict | None: - """Return the next RxPacket from the buffer or the wire. - - Returns None on timeout (no packet available). - """ - if _rx_queue: - return _rx_queue.popleft() - data = read_frame(ser) - if data is None: - return None - resp = decode_response(data) - if resp["type"] == "RxPacket": - return resp - return None - - -def drain_rx(ser: serial.Serial) -> list[dict]: - """Drain all buffered and pending RxPacket frames.""" - packets = list(_rx_queue) - _rx_queue.clear() - old_timeout = ser.timeout - ser.timeout = 0.01 - while True: - data = read_frame(ser) - if data is None: - break - resp = decode_response(data) - if resp["type"] == "RxPacket": - packets.append(resp) - ser.timeout = old_timeout - return packets - - -# ── Mux client support ────────────────────────────────────────── - -class MuxConnection: - """Drop-in replacement for serial.Serial that talks to the mux daemon.""" - - def __init__(self, sock: socket.socket, timeout: float = 2.0): - self._sock = sock - self._timeout = timeout - self._sock.settimeout(timeout) - - @property - def timeout(self) -> float: - return self._timeout - - @timeout.setter - def timeout(self, value: float) -> None: - self._timeout = value - self._sock.settimeout(value) - - def read(self, n: int = 1) -> bytes: - try: - data = self._sock.recv(n) - if not data: - raise ConnectionError("mux disconnected") - return data - except socket.timeout: - return b"" - - def write(self, data: bytes) -> int: - self._sock.sendall(data) - return len(data) - - def flush(self) -> None: - pass - - def reset_input_buffer(self) -> None: - pass - - def close(self) -> None: - self._sock.close() - - -def _mux_socket_path() -> str | None: - """Resolve the mux socket path, or None if no socket exists.""" - env = os.environ.get("DONGLORA_MUX") - if env: - return env if os.path.exists(env) else None - xdg = os.environ.get("XDG_RUNTIME_DIR") - if xdg: - p = os.path.join(xdg, "donglora", "mux.sock") - if os.path.exists(p): - return p - p = "/tmp/donglora-mux.sock" - return p if os.path.exists(p) else None - - -def mux_connect(path: str | None = None, timeout: float = 2.0) -> MuxConnection: - """Connect to the DongLoRa mux daemon via Unix socket.""" - if path is None: - path = _mux_socket_path() - if path is None: - raise FileNotFoundError("No mux socket found") - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(path) - return MuxConnection(sock, timeout) - - -def mux_tcp_connect(host: str, port: int, timeout: float = 2.0) -> MuxConnection: - """Connect to the DongLoRa mux daemon via TCP.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((host, port)) - return MuxConnection(sock, timeout) - - -_mux_mode: tuple | None = None # ("unix",) or ("tcp", host, port) once sticky - - -def _reconnect_mux(timeout: float) -> MuxConnection: - """Retry the mux connection until it comes back.""" - assert _mux_mode is not None - print("Waiting for mux...", end="", flush=True) - while True: - try: - if _mux_mode[0] == "unix": - return mux_connect(timeout=timeout) - else: - return mux_tcp_connect(_mux_mode[1], _mux_mode[2], timeout) - except (FileNotFoundError, ConnectionRefusedError, OSError): - print(".", end="", flush=True) - time.sleep(1) - - -def connect(port: str | None = None, timeout: float = 2.0) -> serial.Serial | MuxConnection: - """Open a connection to a DongLoRa device. - - Priority: DONGLORA_MUX_TCP env var → Unix socket mux → direct USB serial. - Once connected via mux, subsequent calls only retry mux (never steals USB). - """ - global _mux_mode - - # Sticky mux — if we connected via mux before, only retry mux - if _mux_mode is not None and port is None: - return _reconnect_mux(timeout) - - if port is None: - # Try local Unix socket mux - sock_path = _mux_socket_path() - if sock_path is not None: - try: - conn = mux_connect(sock_path, timeout=timeout) - _mux_mode = ("unix",) - return conn - except (ConnectionRefusedError, OSError): - pass # stale socket — fall through - - # Try TCP mux (e.g. remote access over Tailscale) - tcp = os.environ.get("DONGLORA_MUX_TCP") - if tcp: - try: - host, _, p = tcp.rpartition(":") - host = host or "localhost" - conn = mux_tcp_connect(host, int(p), timeout) - _mux_mode = ("tcp", host, int(p)) - return conn - except (ConnectionRefusedError, OSError): - pass # mux not reachable — fall through - - # No mux available — direct USB serial - if port is None: - port = wait_for_device() - ser = serial.Serial(port, baudrate=115200, timeout=timeout) - ser.reset_input_buffer() - return ser - - -# Sentinel: set TX power to the board's maximum -TX_POWER_MAX = -128 # i8::MIN on the wire - -# Sentinel: use firmware default preamble length (16 symbols) -PREAMBLE_DEFAULT = 0 - -# Default radio config: 915 MHz, 125 kHz BW, SF7, CR 4/5, max power, default preamble -DEFAULT_CONFIG = { - "freq_hz": 915_000_000, - "bw": 7, # 125 kHz - "sf": 7, - "cr": 5, # CR 4/5 - "sync_word": 0x1424, - "tx_power_dbm": TX_POWER_MAX, - "preamble_len": PREAMBLE_DEFAULT, -} diff --git a/justfile b/justfile index b50e250..c9aadd1 100644 --- a/justfile +++ b/justfile @@ -6,8 +6,8 @@ default: check _ensure_tools: @mise trust --yes . 2>/dev/null; mise install --quiet -# Run all checks (fmt, lint) -check: fmt-check lint +# Run all checks (fmt, lint, test) +check: fmt-check lint test # Format code fmt: _ensure_tools @@ -20,3 +20,7 @@ fmt-check: _ensure_tools # Lint lint: _ensure_tools @uv run ruff check . + +# Run tests +test: _ensure_tools + @uv run pytest -v diff --git a/pyproject.toml b/pyproject.toml index f369248..fff34fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,20 @@ [project] -name = "donglora-python" -version = "0.1.0" +name = "donglora" +version = "0.2.0" description = "DongLoRa host library — talk LoRa from Python over USB" license = "MIT" requires-python = ">=3.10" -authors = [{ name = "Stephen Waits" }] -keywords = ["lora", "radio", "usb", "embedded"] +readme = "README.md" +authors = [{ name = "Stephen Waits", email = "steve@waits.net" }] +keywords = ["lora", "radio", "usb", "embedded", "serial"] classifiers = [ "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Communications :: Ham Radio", "Topic :: System :: Hardware", ] @@ -20,9 +27,27 @@ dependencies = [ meshcore = ["pycryptodome", "pynacl"] orac = ["pycryptodome", "pynacl", "anthropic"] +[project.urls] +Repository = "https://github.com/donglora/client-py" +Homepage = "https://github.com/donglora/client-py" + [dependency-groups] -dev = ["ruff"] +dev = ["ruff", "pytest", "pytest-cov"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "UP", "B", "C4", "ARG", "SIM", "RUF"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["donglora"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..68b1528 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,135 @@ +"""Tests for donglora.client — send/recv/drain_rx/validate.""" + +from __future__ import annotations + +import struct +from io import BytesIO +from typing import Any + +from donglora.client import _rx_queue, drain_rx, recv, send, validate +from donglora.codec import cobs_encode + + +def _make_mock(responses: list[bytes]) -> Any: + """Create a mock transport that returns pre-encoded COBS frames.""" + wire = b"" + for resp in responses: + wire += cobs_encode(resp) + + class Mock: + def __init__(self) -> None: + self._stream = BytesIO(wire) + self.timeout = 2.0 + self._written: list[bytes] = [] + + def read(self, n: int = 1) -> bytes: + return self._stream.read(n) + + def write(self, data: bytes) -> int: + self._written.append(data) + return len(data) + + def flush(self) -> None: + pass + + return Mock() + + +def _pong() -> bytes: + return bytes([0]) + + +def _ok() -> bytes: + return bytes([4]) + + +def _rx_packet(payload: bytes, rssi: int = -80, snr: int = 10) -> bytes: + return ( + bytes([2]) + + struct.pack(" None: + _rx_queue.clear() + + def test_basic_command(self) -> None: + mock = _make_mock([_pong()]) + resp = send(mock, "Ping") + assert resp["type"] == "Pong" + + def test_buffers_rx_packets(self) -> None: + mock = _make_mock([_rx_packet(b"pkt1"), _rx_packet(b"pkt2"), _ok()]) + resp = send(mock, "StartRx") + assert resp["type"] == "Ok" + assert len(_rx_queue) == 2 + + def test_timeout(self) -> None: + mock = _make_mock([]) + resp = send(mock, "Ping") + assert resp["type"] == "Timeout" + + +class TestRecv: + def setup_method(self) -> None: + _rx_queue.clear() + + def test_from_buffer(self) -> None: + _rx_queue.append({"type": "RxPacket", "rssi": -80, "snr": 10, "payload": b"buf"}) + mock = _make_mock([]) + pkt = recv(mock) + assert pkt is not None + assert pkt["payload"] == b"buf" + + def test_from_wire(self) -> None: + mock = _make_mock([_rx_packet(b"wire")]) + pkt = recv(mock) + assert pkt is not None + assert pkt["payload"] == b"wire" + + def test_timeout(self) -> None: + mock = _make_mock([]) + assert recv(mock) is None + + +class TestDrainRx: + def setup_method(self) -> None: + _rx_queue.clear() + + def test_drains_buffer_and_wire(self) -> None: + _rx_queue.append({"type": "RxPacket", "rssi": 0, "snr": 0, "payload": b"buf"}) + mock = _make_mock([_rx_packet(b"wire")]) + packets = drain_rx(mock) + assert len(packets) == 2 + assert packets[0]["payload"] == b"buf" + assert packets[1]["payload"] == b"wire" + assert len(_rx_queue) == 0 + + +class TestValidate: + def setup_method(self) -> None: + _rx_queue.clear() + + def test_success(self) -> None: + mock = _make_mock([_pong()]) + validate(mock) + + def test_failure(self) -> None: + mock = _make_mock([_ok()]) + try: + validate(mock) + assert False, "should have raised" # noqa: B011 + except ConnectionError: + pass + + def test_timeout(self) -> None: + mock = _make_mock([]) + try: + validate(mock) + assert False, "should have raised" # noqa: B011 + except ConnectionError: + pass diff --git a/tests/test_codec.py b/tests/test_codec.py new file mode 100644 index 0000000..5184ff6 --- /dev/null +++ b/tests/test_codec.py @@ -0,0 +1,75 @@ +"""Tests for donglora.codec — COBS framing.""" + +from __future__ import annotations + +from io import BytesIO + +from cobs import cobs + +from donglora.codec import cobs_encode, read_frame + + +class TestCobsEncode: + def test_basic(self) -> None: + data = b"hello" + encoded = cobs_encode(data) + assert encoded[-1:] == b"\x00" + assert cobs.decode(encoded[:-1]) == data + + def test_empty(self) -> None: + encoded = cobs_encode(b"") + assert encoded[-1:] == b"\x00" + assert cobs.decode(encoded[:-1]) == b"" + + def test_zeros(self) -> None: + data = b"\x00\x00\x00" + encoded = cobs_encode(data) + assert b"\x00" not in encoded[:-1] + assert cobs.decode(encoded[:-1]) == data + + def test_roundtrip(self) -> None: + for length in [0, 1, 10, 100, 254, 255, 256]: + data = bytes(range(length % 256)) * (length // 256 + 1) + data = data[:length] + encoded = cobs_encode(data) + decoded = cobs.decode(encoded[:-1]) + assert decoded == data + + +class _MockSerial: + """Minimal mock that behaves like serial.Serial for read_frame.""" + + def __init__(self, data: bytes) -> None: + self._stream = BytesIO(data) + + def read(self, n: int = 1) -> bytes: + return self._stream.read(n) + + +class TestReadFrame: + def test_basic(self) -> None: + data = b"hello" + frame = cobs_encode(data) + result = read_frame(_MockSerial(frame)) + assert result == data + + def test_timeout(self) -> None: + result = read_frame(_MockSerial(b"")) + assert result is None + + def test_empty_frame(self) -> None: + result = read_frame(_MockSerial(b"\x00")) + assert result is None + + def test_corrupt_frame(self) -> None: + result = read_frame(_MockSerial(b"\xff\x00")) + # cobs.decode of [0xff] may or may not raise — just verify we get bytes or None + assert result is None or isinstance(result, bytes) + + def test_multiple_frames(self) -> None: + frame1 = cobs_encode(b"first") + frame2 = cobs_encode(b"second") + mock = _MockSerial(frame1 + frame2) + assert read_frame(mock) == b"first" + assert read_frame(mock) == b"second" + assert read_frame(mock) is None diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..67fcab4 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,254 @@ +"""Tests for donglora.protocol — ported from client-rs/src/protocol.rs tests.""" + +from __future__ import annotations + +import struct + +import pytest + +from donglora.protocol import ( + DEFAULT_CONFIG, + MAX_PAYLOAD, + PREAMBLE_DEFAULT, + RADIO_CONFIG_SIZE, + TX_POWER_MAX, + Bandwidth, + ErrorCode, + RadioConfig, + decode_config, + decode_response, + encode_command, + encode_config, +) + + +def _make_config() -> RadioConfig: + return { + "freq_hz": 915_000_000, + "bw": Bandwidth.Khz125, + "sf": 7, + "cr": 5, + "sync_word": 0x3444, + "tx_power_dbm": 22, + "preamble_len": 16, + "cad": 1, + } + + +# ── RadioConfig ────────────────────────────────────────────────── + + +class TestRadioConfig: + def test_roundtrip(self) -> None: + cfg = _make_config() + encoded = encode_config(cfg) + assert len(encoded) == RADIO_CONFIG_SIZE + decoded = decode_config(encoded) + assert decoded == cfg + + def test_default_roundtrip(self) -> None: + encoded = encode_config(DEFAULT_CONFIG) + decoded = decode_config(encoded) + assert decoded is not None + assert decoded["freq_hz"] == DEFAULT_CONFIG["freq_hz"] + assert decoded["bw"] == DEFAULT_CONFIG["bw"] + assert decoded["sf"] == DEFAULT_CONFIG["sf"] + assert decoded["cr"] == DEFAULT_CONFIG["cr"] + assert decoded["sync_word"] == DEFAULT_CONFIG["sync_word"] + assert decoded["tx_power_dbm"] == DEFAULT_CONFIG["tx_power_dbm"] + assert decoded["cad"] == DEFAULT_CONFIG["cad"] + + def test_all_bandwidths(self) -> None: + for bw in Bandwidth: + cfg = {**_make_config(), "bw": bw} + encoded = encode_config(cfg) + decoded = decode_config(encoded) + assert decoded is not None + assert decoded["bw"] == bw + + def test_invalid_bandwidth(self) -> None: + buf = bytearray(encode_config(_make_config())) + buf[4] = 255 + assert decode_config(bytes(buf)) is None + + def test_negative_power(self) -> None: + cfg = {**_make_config(), "tx_power_dbm": TX_POWER_MAX} + encoded = encode_config(cfg) + decoded = decode_config(encoded) + assert decoded is not None + assert decoded["tx_power_dbm"] == TX_POWER_MAX + + def test_short_buffer(self) -> None: + assert decode_config(bytes(12)) is None + assert decode_config(b"") is None + + +# ── Command encoding ───────────────────────────────────────────── + + +class TestCommandEncoding: + def test_simple_tags(self) -> None: + assert encode_command("Ping") == bytes([0]) + assert encode_command("GetConfig") == bytes([1]) + assert encode_command("StartRx") == bytes([3]) + assert encode_command("StopRx") == bytes([4]) + assert encode_command("DisplayOn") == bytes([6]) + assert encode_command("DisplayOff") == bytes([7]) + assert encode_command("GetMac") == bytes([8]) + + def test_set_config(self) -> None: + cfg = _make_config() + cmd = encode_command("SetConfig", config=cfg) + assert cmd[0] == 2 + assert len(cmd) == 1 + RADIO_CONFIG_SIZE + decoded = decode_config(cmd[1:]) + assert decoded == cfg + + def test_transmit_no_config(self) -> None: + cmd = encode_command("Transmit", payload=b"hello") + assert cmd[0] == 5 + assert cmd[1] == 0 # has_config = false + length = struct.unpack_from(" None: + cfg = _make_config() + cmd = encode_command("Transmit", config=cfg, payload=b"test") + assert cmd[0] == 5 + assert cmd[1] == 1 # has_config = true + decoded = decode_config(cmd[2:]) + assert decoded == cfg + offset = 2 + RADIO_CONFIG_SIZE + length = struct.unpack_from(" None: + cmd = encode_command("Transmit", payload=b"") + assert cmd[0] == 5 + length = struct.unpack_from(" None: + with pytest.raises(ValueError, match="payload too large"): + encode_command("Transmit", payload=bytes(MAX_PAYLOAD + 1)) + + def test_transmit_max_payload(self) -> None: + cmd = encode_command("Transmit", payload=bytes(MAX_PAYLOAD)) + length = struct.unpack_from(" None: + with pytest.raises(ValueError, match="unknown command"): + encode_command("FakeCommand") + + +# ── Response decoding ──────────────────────────────────────────── + + +class TestResponseDecoding: + def test_pong(self) -> None: + assert decode_response(bytes([0])) == {"type": "Pong"} + + def test_config(self) -> None: + cfg = _make_config() + resp = decode_response(bytes([1]) + encode_config(cfg)) + assert resp["type"] == "Config" + assert resp["freq_hz"] == cfg["freq_hz"] + assert resp["bw"] == cfg["bw"] + assert resp["sf"] == cfg["sf"] + assert resp["cr"] == cfg["cr"] + assert resp["sync_word"] == cfg["sync_word"] + assert resp["tx_power_dbm"] == cfg["tx_power_dbm"] + assert resp["preamble_len"] == cfg["preamble_len"] + assert resp["cad"] == cfg["cad"] + + def test_rx_packet(self) -> None: + payload = b"data" + data = bytes([2]) + data += struct.pack(" None: + data = bytes([2]) + data += struct.pack(" None: + assert decode_response(bytes([3])) == {"type": "TxDone"} + + def test_ok(self) -> None: + assert decode_response(bytes([4])) == {"type": "Ok"} + + def test_error_codes(self) -> None: + for code in ErrorCode: + resp = decode_response(bytes([5, code])) + assert resp["type"] == "Error" + assert resp["code"] == code + + def test_mac_address(self) -> None: + mac_bytes = bytes([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + resp = decode_response(bytes([6]) + mac_bytes) + assert resp["type"] == "MacAddress" + assert resp["mac"] == "AA:BB:CC:DD:EE:FF" + + def test_empty(self) -> None: + assert decode_response(b"") == {"type": "Empty"} + + def test_unknown_tag(self) -> None: + resp = decode_response(bytes([255])) + assert resp["type"] == "Unknown(255)" + + +# ── Cross-compatibility with firmware encoding ──────────────────── + + +class TestFirmwareCompatibility: + def test_worked_example(self) -> None: + """From PROTOCOL.md: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power.""" + cfg: RadioConfig = { + "freq_hz": 915_000_000, + "bw": Bandwidth.Khz125, + "sf": 7, + "cr": 5, + "sync_word": 0x1424, + "tx_power_dbm": TX_POWER_MAX, + "preamble_len": PREAMBLE_DEFAULT, + "cad": 1, + } + cmd = encode_command("SetConfig", config=cfg) + expected = bytes( + [0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01] + ) + assert cmd == expected + + +# ── Enum coverage ──────────────────────────────────────────────── + + +class TestEnums: + def test_bandwidth_values(self) -> None: + assert Bandwidth.Khz7 == 0 + assert Bandwidth.Khz500 == 9 + assert len(Bandwidth) == 10 + + def test_error_code_values(self) -> None: + assert ErrorCode.InvalidConfig == 0 + assert ErrorCode.RadioBusy == 1 + assert ErrorCode.TxTimeout == 2 + assert ErrorCode.CrcError == 3 + assert ErrorCode.NotConfigured == 4 + assert ErrorCode.NoDisplay == 5 + assert len(ErrorCode) == 6 diff --git a/uv.lock b/uv.lock index 6e68fde..a86ec79 100644 --- a/uv.lock +++ b/uv.lock @@ -149,6 +149,133 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/64/390e226843cc83066acc60257e0e71ebdbce6dd915ec4f3d34101b9ac31b/cobs-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1f27b7a553d809487287e4ed88540b2c30ed9c9bb596173bfcf549e705af7bf0", size = 28205, upload-time = "2026-03-09T02:18:20.193Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -169,7 +296,7 @@ wheels = [ [[package]] name = "donglora" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "cobs" }, @@ -187,6 +314,13 @@ orac = [ { name = "pynacl" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'orac'" }, @@ -199,6 +333,13 @@ requires-dist = [ ] provides-extras = ["meshcore", "orac"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -257,6 +398,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +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 = "jiter" version = "0.13.0" @@ -354,6 +504,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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 = "3.0" @@ -531,6 +699,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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 = "pynacl" version = "1.6.2" @@ -575,6 +752,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -584,6 +818,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"