diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f6f53cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pyrefly check + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.14"] + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - run: uv sync --locked + - run: uv run pytest + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + - run: uv build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5138119 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# holman-linux-python + +Python SDK and `holmanctl` CLI for controlling Holman Bluetooth tap timers (CO3015, BTX1/CO3011) on Linux via BlueZ. + +## Tech Stack +- Python 3, built on the `gatt` library (which wraps BlueZ's D-Bus API) +- `python3-dbus` comes from apt, not PyPI, and is not in `dependencies` +- Managed with uv; `pyproject.toml` (hatchling backend) is the only packaging config; published to PyPI as `holman` + +## Layout +- `holman/holman.py` — the whole SDK: `TapTimerManager` (discovery), `TapTimer` (connection + control), and the two listener base classes +- `holman/__init__.py` — public API re-exports; update it when adding public classes +- `holmanctl.py` — demo/test CLI, installed as the `holmanctl` console script +- `tests/` — pytest suite; `conftest.py` swaps `gatt` for an in-memory fake (real `gatt` calls `sys.exit` on import without dbus) +- `typings/gatt/` — pyrefly stub pinning `gatt` to its BlueZ backend (it picks a backend at runtime, which the checker can't narrow) + +## Development +- Before finishing a change, run `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .` and `uv run pyrefly check` +- CI (`.github/workflows/ci.yml`) runs the same checks, with pytest on Python 3.9 and 3.14; keep `requires-python` and that matrix in sync +- Ruff formats the Python snippets in `README.md` too +- Tests run with `--import-mode=importlib`, so share test helpers as fixtures in `conftest.py`, not by importing modules from `tests/` +- Tests cover logic only; when adding a `gatt` API call, add it to the fakes in `tests/conftest.py` +- Real verification needs hardware: a tap timer in range, `bluetoothd` running, and root (`sudo holmanctl --discover`, `--connect `) +- Event-driven: nothing happens until `TapTimerManager.run()` starts the GLib loop; results arrive through listener callbacks, not return values + +## Device Protocol +- Supported models are identified by GATT service UUID — `holman/holman.py:90-95`. A new model means adding its UUID to `SERVICE_UUIDS` +- Discovery also filters on the BLE alias `'Tap Timer'` — `holman/holman.py:41` +- Start/stop write a 4-byte value to the manual characteristic (byte 0 = on/off, byte 3 = minutes, capped at 255) — `holman/holman.py:228-243` +- On/off state is the last byte of the state characteristic — `holman/holman.py:217-221` +- Battery level is not decoded yet (`battery_level()` always returns `None`) + +## Releasing +- Bump `version` in `pyproject.toml`, then `uv lock`, `uv build` and `uv publish` + +## Python Version + +Python 3.9 minimum (`requires-python` in pyproject.toml; ruff and pyrefly also target 3.9). Use the newest syntax 3.9 supports: +- `list[str]` instead of `List[str]` +- `dict[str, Any]` instead of `Dict[str, Any]` +- `tuple[int, str]` instead of `Tuple[int, str]` +- `X | None` instead of `Optional[X]` only in annotations, with `from __future__ import annotations` at the top of the module; `|` between types fails at runtime on 3.9 anywhere else (e.g. `isinstance`, aliases) +- No `match` statements (3.10) or `type X = ...` aliases (3.12); use `if`/`elif` and plain `X = ...` alias assignments (`typing.TypeAlias` is 3.10+, and `typing_extensions` isn't a dependency) + +## Manage packages + +This project uses uv. Do not use pip, pip-tools, poetry, or conda. + +- Add runtime dependency: `uv add ` (writes to `[project.dependencies]`) +- Add dev dependency: `uv add --dev ` (writes to `[dependency-groups]` per PEP 735) +- Remove dependency: `uv remove ` +- Sync environment from lockfile: `uv sync` +- Regenerate lockfile from constraints: `uv lock` +- Upgrade locked versions: `uv lock --upgrade` +- Commit `uv.lock` to version control (current uv guidance is to commit it for applications, CLIs, and libraries) + +## Run code + +Always use `uv run` to execute Python code and tools. Never call `python`, `pytest`, `ruff`, or other tools directly. They may not resolve to the project's virtual environment. + +- Run a script: `uv run python script.py` +- Run a module: `uv run python -m module_name` +- Run a tool: `uv run pytest`, `uv run ruff check .` +- One-off tool (not a project dependency): `uvx ` + +## Test code + +- Framework: pytest +- Run tests: `uv run pytest` +- Test files go in `tests/` at the project root +- Test file naming: `test_*.py` +- Test function naming: `test_*` +- No `__init__.py` needed in `tests/` +- Coverage: `uv add --dev coverage`, then `uv run coverage run -m pytest` and `uv run coverage report` + +## Lint and format code + +- Tool: ruff (handles both linting and formatting) +- Lint: `uv run ruff check .` +- Lint and auto-fix: `uv run ruff check --fix .` +- Format: `uv run ruff format .` +- Check formatting: `uv run ruff format --check .` +- Configuration lives in `pyproject.toml` under `[tool.ruff]` + +## Check types + +- Tool: pyrefly (preferred), ty, or mypy +- Run: `uv run pyrefly check`, `uv run ty check`, or `uv run mypy .` +- Configuration lives in `pyproject.toml` under `[tool.pyrefly]`, `[tool.ty]`, or `[tool.mypy]` + +## Avoid these practices + +- Do not create or activate virtual environments manually. uv manages `.venv/` automatically. +- Do not install packages globally or with `pip install`. +- Do not create `requirements.txt` for dependency management. Use `pyproject.toml` and `uv.lock`. +- Do not run `python setup.py` commands. +- Do not add dependencies to pyproject.toml by hand. Use `uv add`. +- If you must edit pyproject.toml directly, write dev dependencies under `[dependency-groups]` (PEP 735), not the legacy `[tool.uv.dev-dependencies]` table. + +## Development Standards + +### Git Workflow +- Branch names: `feature/description` or `fix/description` +- Commit messages: Follow conventional commits +- PR required before merge +- All CI/CD checks must pass +- Minimum 1 approval required + +### Testing Requirements +- Minimum 80% code coverage +- All critical paths must have tests + diff --git a/README.md b/README.md index 3fa413f..e430648 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ and [BTX1](https://www.holmanindustries.com.au/products/btx1-tap-mounted-smart-v The Holman Python SDK for Linux allows you to integrate your Holman(s) into any type of Linux application or script that can execute Python code. ## Prerequisites -The Holman SDK requires [Python 3.4+](https://www.python.org) and a recent installation of [BlueZ](http://www.bluez.org/). It is tested to work fine with BlueZ 5.44, slightly older versions should however work, too. +The Holman SDK requires [Python 3.9+](https://www.python.org) and a recent installation of [BlueZ](http://www.bluez.org/). It is tested to work fine with BlueZ 5.44, slightly older versions should however work, too. ## Installation These instructions assume a Debian-based Linux. @@ -87,11 +87,13 @@ Please note that communication with your Bluetooth adapter happens over BlueZ's ```python import holman + class TapTimerManagerPrintListener(holman.TapTimerManagerListener): def tap_timer_discovered(self, tap_timer): print("Discovered Holman tap_timer", tap_timer.mac_address) -manager = holman.TapTimerManager(adapter_name='hci0') + +manager = holman.TapTimerManager(adapter_name="hci0") manager.listener = TapTimerManagerPrintListener() manager.start_discovery() manager.run() @@ -108,10 +110,11 @@ The following example connects to a Holman tap timer manually: ```python import holman -manager = holman.TapTimerManager(adapter_name='hci0') +manager = holman.TapTimerManager(adapter_name="hci0") -tap_timer = holman.TapTimer(mac_address='AA:BB:CC:DD:EE:FF', manager=manager) -tap_timer.listener = holman.TapTimerListener() # Use an instance of your own holman.TapTimerListener subclass +tap_timer = holman.TapTimer(mac_address="AA:BB:CC:DD:EE:FF", manager=manager) +# Use an instance of your own holman.TapTimerListener subclass +tap_timer.listener = holman.TapTimerListener() tap_timer.connect() manager.run() @@ -123,6 +126,28 @@ As with Holman tap timer discovery, remember to start the Bluetooth event loop w Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass this a runtime (in minutes) for how long to run the tap. +## Development + +The project uses [uv](https://docs.astral.sh/uv/) for environment and dependency management. + +``` +uv sync # create .venv with the SDK and dev tools +uv run pytest # run the tests (no Bluetooth hardware needed) +uv run ruff check . # lint +uv run ruff format . # format +uv run pyrefly check # type check +uv build # build sdist and wheel into dist/ +``` + +The tests replace the `gatt` library with an in-memory fake (`tests/conftest.py`), so they don't need `python3-dbus` or `bluetoothd`. Testing against a real tap timer still requires the system setup above; to use the apt-installed `python3-dbus` and `python3-gi` from the uv environment, create it on the system interpreter they were built for, then run `holmanctl` from it: + +``` +sudo apt-get install python3-dbus python3-gi +uv venv --system-site-packages --python /usr/bin/python3 +uv sync +sudo .venv/bin/holmanctl --discover +``` + ## Support Please open an issue. diff --git a/holman/__init__.py b/holman/__init__.py index 1456ceb..9e98b5a 100644 --- a/holman/__init__.py +++ b/holman/__init__.py @@ -1 +1,8 @@ -from .holman import TapTimerManager, TapTimerManagerListener, TapTimer, TapTimerListener +from .holman import TapTimer, TapTimerListener, TapTimerManager, TapTimerManagerListener + +__all__ = [ + "TapTimer", + "TapTimerListener", + "TapTimerManager", + "TapTimerManagerListener", +] diff --git a/holman/holman.py b/holman/holman.py index 1d287d1..6875c0e 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -1,6 +1,7 @@ """ Module for managing Holman Bluetooth tap timers. """ + import gatt @@ -9,7 +10,7 @@ class TapTimerManager(gatt.DeviceManager): Entry point for managing and discovering Holman ``TapTimer``s. """ - def __init__(self, adapter_name='hci0'): + def __init__(self, adapter_name="hci0"): """ Instantiates a ``TapTimerManager`` @@ -36,9 +37,8 @@ def start_discovery(self, service_uuids=None): super().start_discovery(service_uuids=TapTimer.SERVICE_UUIDS) def make_device(self, mac_address): - device = gatt.Device( - mac_address=mac_address, manager=self, managed=False) - if device.alias() != 'Tap Timer': + device = gatt.Device(mac_address=mac_address, manager=self, managed=False) + if device.alias() != "Tap Timer": return None return TapTimer(mac_address=mac_address, manager=self) @@ -51,7 +51,7 @@ def device_discovered(self, device): self.listener.tap_timer_discovered(device) -class TapTimerManagerListener(object): +class TapTimerManagerListener: """ Base class for receiving discovery events from ``TapTimerManager``. @@ -87,14 +87,12 @@ class TapTimer(gatt.Device): notified with all events """ - HOLMAN_CO3015_SERVICE_UUID = '0a75f000-f9ad-467a-e564-3c19163ad543' - HOLMAN_CO3011_SERVICE_UUID = 'c521f000-0d70-4d4f-8e43-40d84c50ab38' # model BTX1 - STATE_CHARACTERISTIC_UUID = '0000f004-0000-1000-8000-00805f9b34fb' - MANUAL_CHARACTERISTIC_UUID = '0000f006-0000-1000-8000-00805f9b34fb' + HOLMAN_CO3015_SERVICE_UUID = "0a75f000-f9ad-467a-e564-3c19163ad543" + HOLMAN_CO3011_SERVICE_UUID = "c521f000-0d70-4d4f-8e43-40d84c50ab38" # model BTX1 + STATE_CHARACTERISTIC_UUID = "0000f004-0000-1000-8000-00805f9b34fb" + MANUAL_CHARACTERISTIC_UUID = "0000f006-0000-1000-8000-00805f9b34fb" - SERVICE_UUIDS = [ - HOLMAN_CO3015_SERVICE_UUID, - HOLMAN_CO3011_SERVICE_UUID] + SERVICE_UUIDS = [HOLMAN_CO3015_SERVICE_UUID, HOLMAN_CO3011_SERVICE_UUID] def __init__(self, mac_address, manager): """ @@ -150,36 +148,56 @@ def disconnect_succeeded(self): def services_resolved(self): super().services_resolved() - holman_service = next(( - service for service in self.services - if service.uuid in self.SERVICE_UUIDS), None) + holman_service = next( + ( + service + for service in self.services + if service.uuid in self.SERVICE_UUIDS + ), + None, + ) if holman_service is None: if self.listener: # TODO: Use proper exception subclass - self.listener.connect_failed( - Exception("Holman GATT service missing")) + self.listener.connect_failed(Exception("Holman GATT service missing")) return - self._manual_characteristic = next(( - char for char in holman_service.characteristics - if char.uuid == self.MANUAL_CHARACTERISTIC_UUID), None) + self._manual_characteristic = next( + ( + char + for char in holman_service.characteristics + if char.uuid == self.MANUAL_CHARACTERISTIC_UUID + ), + None, + ) if self._manual_characteristic is None: - # TODO: Use proper exception subclass - self.listener.connect_failed( - Exception( - "Holman GATT characteristic %s missing", - self.MANUAL_CHARACTERISTIC_UUID)) + if self.listener: + # TODO: Use proper exception subclass + self.listener.connect_failed( + Exception( + f"Holman GATT characteristic " + f"{self.MANUAL_CHARACTERISTIC_UUID} missing" + ) + ) return - self._state_characteristic = next(( - char for char in holman_service.characteristics - if char.uuid == self.STATE_CHARACTERISTIC_UUID), None) + self._state_characteristic = next( + ( + char + for char in holman_service.characteristics + if char.uuid == self.STATE_CHARACTERISTIC_UUID + ), + None, + ) if self._state_characteristic is None: - # TODO: Use proper exception subclass - self.listener.connect_failed( - Exception( - "Holman GATT characteristic %s missing", - self.STATE_CHARACTERISTIC_UUID)) + if self.listener: + # TODO: Use proper exception subclass + self.listener.connect_failed( + Exception( + f"Holman GATT characteristic " + f"{self.STATE_CHARACTERISTIC_UUID} missing" + ) + ) return self._refresh_state() @@ -236,6 +254,7 @@ class TapTimerListener: Base class of listeners for a ``TapTimer`` with empty handler implementations. """ + def started_connecting(self): pass diff --git a/holmanctl.py b/holmanctl.py index 55b17a1..87152e6 100755 --- a/holmanctl.py +++ b/holmanctl.py @@ -2,15 +2,15 @@ import sys from argparse import ArgumentParser -import holman -tap_timer_manager = None +import holman class TapTimerPrintListener(holman.TapTimerListener): """ An implementation of ``TapTimerListener`` that prints each event. """ + def __init__(self, tap_timer): self.tap_timer = tap_timer @@ -40,7 +40,7 @@ def __init__(self, tap_timer, auto_reconnect=False): def connect_failed(self, error): super().connect_failed(error) - tap_timer_manager.stop() + self.tap_timer.manager.stop() sys.exit(0) def disconnect_succeeded(self): @@ -51,7 +51,7 @@ def disconnect_succeeded(self): print("Disconnected, reconnecting...") self.tap_timer.connect() else: - tap_timer_manager.stop() + self.tap_timer.manager.stop() sys.exit(0) @@ -63,36 +63,38 @@ def tap_timer_discovered(self, tap_timer): def main(): arg_parser = ArgumentParser(description="Holman Tap Timer Demo") arg_parser.add_argument( - '--adapter', - default='hci0', - help="Name of Bluetooth adapter, defaults to 'hci0'") + "--adapter", + default="hci0", + help="Name of Bluetooth adapter, defaults to 'hci0'", + ) arg_commands_group = arg_parser.add_mutually_exclusive_group(required=True) arg_commands_group.add_argument( - '--discover', - action='store_true', - help="Lists all nearby Holman tap timers") + "--discover", action="store_true", help="Lists all nearby Holman tap timers" + ) arg_commands_group.add_argument( - '--known', - action='store_true', - help="Lists all known Holman tap timers") + "--known", action="store_true", help="Lists all known Holman tap timers" + ) arg_commands_group.add_argument( - '--connect', - metavar='address', + "--connect", + metavar="address", type=str, - help="Connect to a Holman tap timer with a given MAC address") + help="Connect to a Holman tap timer with a given MAC address", + ) arg_commands_group.add_argument( - '--auto', - metavar='address', + "--auto", + metavar="address", type=str, - help="Connect and automatically reconnect to a Holman tap timer with a given MAC address") + help="Connect and automatically reconnect to a Holman tap timer " + "with a given MAC address", + ) arg_commands_group.add_argument( - '--disconnect', - metavar='address', + "--disconnect", + metavar="address", type=str, - help="Disconnect a Holman tap timer with a given MAC address") + help="Disconnect a Holman tap timer with a given MAC address", + ) args = arg_parser.parse_args() - global tap_timer_manager tap_timer_manager = holman.TapTimerManager(adapter_name=args.adapter) if args.discover: @@ -100,7 +102,7 @@ def main(): tap_timer_manager.start_discovery() if args.known: for tap_timer in tap_timer_manager.tap_timers(): - print("[%s] %s" % (tap_timer.mac_address, tap_timer.alias())) + print(f"[{tap_timer.mac_address}] {tap_timer.alias()}") return elif args.connect: tap_timer = holman.TapTimer(mac_address=args.connect, manager=tap_timer_manager) @@ -108,10 +110,14 @@ def main(): tap_timer.connect() elif args.auto: tap_timer = holman.TapTimer(mac_address=args.auto, manager=tap_timer_manager) - tap_timer.listener = TapTimerTestListener(tap_timer=tap_timer, auto_reconnect=True) + tap_timer.listener = TapTimerTestListener( + tap_timer=tap_timer, auto_reconnect=True + ) tap_timer.connect() elif args.disconnect: - tap_timer = holman.TapTimer(mac_address=args.disconnect, manager=tap_timer_manager) + tap_timer = holman.TapTimer( + mac_address=args.disconnect, manager=tap_timer_manager + ) tap_timer.disconnect() return @@ -121,5 +127,6 @@ def main(): except KeyboardInterrupt: pass -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2caba1f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "holman" +version = "0.1.0" +description = "Holman SDK for Python on Linux" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE.txt"] +authors = [{ name = "Scott McKenzie", email = "developers@noizyland.net" }] +keywords = ["holman"] +requires-python = ">=3.9" +# python3-dbus is also required at runtime, but comes from apt (not PyPI). +dependencies = ["gatt>=0.2.7"] + +[project.urls] +Homepage = "https://github.com/scottmckenzie/holman-linux-python" + +[project.scripts] +holmanctl = "holmanctl:main" + +[dependency-groups] +dev = [ + "pyrefly>=1.3", + "pytest>=8", + "ruff>=0.17", +] + +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +only-include = ["holman", "holmanctl.py"] + +[tool.hatch.build.targets.sdist] +exclude = ["/.claude", "/CLAUDE.md", "/uv.lock"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["--import-mode=importlib"] + +[tool.ruff] +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] + +[tool.pyrefly] +project-includes = ["holman", "holmanctl.py", "tests"] +python-version = "3.9" +search-path = ["typings"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b88034e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md diff --git a/setup.py b/setup.py deleted file mode 100644 index 31d41eb..0000000 --- a/setup.py +++ /dev/null @@ -1,21 +0,0 @@ -from setuptools import setup - -setup( - name='holman', - packages=['holman'], - install_requires=[ - 'gatt>=0.2.7' - ], - version='0.1.0', - description='Holman SDK for Python on Linux', - keywords='holman', - url='https://github.com/scottmckenzie/holman-linux-python', - download_url='https://github.com/scottmckenzie/holman-linux-python/archive/0.1.0.tar.gz', - author='Scott McKenzie', - author_email='developers@noizyland.net', - license='MIT', - py_modules=['holmanctl'], - entry_points={ - 'console_scripts': ['holmanctl = holmanctl:main'] - } -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f5dc59c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,151 @@ +""" +Test setup: replace the ``gatt`` library with an in-memory fake. + +The real ``gatt`` needs ``python3-dbus`` and a running ``bluetoothd`` (and +calls ``sys.exit`` on import without dbus), so tests never import it. +""" + +import sys +import types + +import pytest + + +class FakeDeviceManager: + def __init__(self, adapter_name): + self.adapter_name = adapter_name + self.listener = None + self.aliases = {} + self.known_devices = [] + self.discovery_uuids = None + self.stopped = False + + def devices(self): + return self.known_devices + + def start_discovery(self, service_uuids=None): + self.discovery_uuids = service_uuids + + def device_discovered(self, device): + pass + + def stop(self): + self.stopped = True + + +class FakeDevice: + def __init__(self, mac_address, manager, managed=True): + self.mac_address = mac_address + self.manager = manager + self.services = [] + self.connect_calls = 0 + self.disconnect_calls = 0 + + def alias(self): + return self.manager.aliases.get(self.mac_address, self.mac_address) + + def connect(self): + self.connect_calls += 1 + + def connect_failed(self, error): + pass + + def disconnect(self): + self.disconnect_calls += 1 + + def disconnect_succeeded(self): + pass + + def services_resolved(self): + pass + + +class FakeService: + def __init__(self, uuid, characteristics=()): + self.uuid = uuid + self.characteristics = list(characteristics) + + +class FakeCharacteristic: + def __init__(self, uuid, value=b"\x00"): + self.uuid = uuid + self.value = value + self.written = [] + + def read_value(self): + return self.value + + def write_value(self, value): + self.written.append(value) + + +fake_gatt = types.ModuleType("gatt") +fake_gatt.DeviceManager = FakeDeviceManager # type: ignore[attr-defined] +fake_gatt.Device = FakeDevice # type: ignore[attr-defined] +sys.modules["gatt"] = fake_gatt + +import holman # noqa: E402 + + +class RecordingListener(holman.TapTimerListener): + def __init__(self): + self.events = [] + + def started_connecting(self): + self.events.append("started_connecting") + + def connect_succeeded(self): + self.events.append("connect_succeeded") + + def connect_failed(self, error): + self.events.append(("connect_failed", str(error))) + + def started_disconnecting(self): + self.events.append("started_disconnecting") + + def disconnect_succeeded(self): + self.events.append("disconnect_succeeded") + + +@pytest.fixture +def manager(): + return holman.TapTimerManager(adapter_name="hci0") + + +@pytest.fixture +def tap_timer(manager): + return holman.TapTimer(mac_address="AA:BB:CC:DD:EE:FF", manager=manager) + + +@pytest.fixture +def listener(tap_timer): + tap_timer.listener = RecordingListener() + return tap_timer.listener + + +@pytest.fixture +def make_service(): + """Factory for a non-Holman GATT service.""" + return FakeService + + +@pytest.fixture +def make_holman_service(): + """Factory for a Holman GATT service with optional characteristics.""" + + def make( + service_uuid=holman.TapTimer.HOLMAN_CO3015_SERVICE_UUID, + state=b"\x00", + manual=True, + state_char=True, + ): + chars = [] + if manual: + chars.append(FakeCharacteristic(holman.TapTimer.MANUAL_CHARACTERISTIC_UUID)) + if state_char: + chars.append( + FakeCharacteristic(holman.TapTimer.STATE_CHARACTERISTIC_UUID, state) + ) + return FakeService(service_uuid, chars) + + return make diff --git a/tests/test_holmanctl.py b/tests/test_holmanctl.py new file mode 100644 index 0000000..5d05aa3 --- /dev/null +++ b/tests/test_holmanctl.py @@ -0,0 +1,48 @@ +import pytest + +import holman +import holmanctl + + +def test_connect_failed_stops_manager_and_exits(tap_timer, manager): + listener = holmanctl.TapTimerTestListener(tap_timer) + with pytest.raises(SystemExit): + listener.connect_failed(Exception("boom")) + assert manager.stopped + + +def test_disconnect_with_auto_reconnect_reconnects(tap_timer, manager): + listener = holmanctl.TapTimerTestListener(tap_timer, auto_reconnect=True) + listener.disconnect_succeeded() + assert tap_timer.connect_calls == 1 + assert not manager.stopped + + +def test_known_lists_tap_timers(monkeypatch, capsys): + class KnownManager(holman.TapTimerManager): + def __init__(self, adapter_name): + super().__init__(adapter_name) + self.aliases["AA:BB:CC:DD:EE:FF"] = "Tap Timer" + self.known_devices = [ + holman.TapTimer(mac_address="AA:BB:CC:DD:EE:FF", manager=self) + ] + + monkeypatch.setattr(holman, "TapTimerManager", KnownManager) + monkeypatch.setattr("sys.argv", ["holmanctl", "--known"]) + holmanctl.main() + assert capsys.readouterr().out == "[AA:BB:CC:DD:EE:FF] Tap Timer\n" + + +def test_requires_a_command(monkeypatch): + monkeypatch.setattr("sys.argv", ["holmanctl"]) + with pytest.raises(SystemExit): + holmanctl.main() + + +def test_public_api(): + assert set(holman.__all__) == { + "TapTimer", + "TapTimerListener", + "TapTimerManager", + "TapTimerManagerListener", + } diff --git a/tests/test_tap_timer.py b/tests/test_tap_timer.py new file mode 100644 index 0000000..00f4e19 --- /dev/null +++ b/tests/test_tap_timer.py @@ -0,0 +1,103 @@ +import pytest + +import holman + +MANUAL = holman.TapTimer.MANUAL_CHARACTERISTIC_UUID + + +def resolve(tap_timer, *services): + tap_timer.services = list(services) + tap_timer.services_resolved() + + +def manual_char(tap_timer): + return next(c for c in tap_timer.services[0].characteristics if c.uuid == MANUAL) + + +@pytest.mark.parametrize("uuid", holman.TapTimer.SERVICE_UUIDS) +def test_services_resolved_connects_for_each_model( + make_holman_service, tap_timer, listener, uuid +): + resolve(tap_timer, make_holman_service(service_uuid=uuid)) + assert listener.events == ["connect_succeeded"] + + +def test_services_resolved_without_holman_service_fails( + make_service, tap_timer, listener +): + resolve(tap_timer, make_service("00001800-0000-1000-8000-00805f9b34fb")) + assert listener.events == [("connect_failed", "Holman GATT service missing")] + + +@pytest.mark.parametrize( + ("kwargs", "missing"), + [ + ({"manual": False}, holman.TapTimer.MANUAL_CHARACTERISTIC_UUID), + ({"state_char": False}, holman.TapTimer.STATE_CHARACTERISTIC_UUID), + ], +) +def test_services_resolved_missing_characteristic_fails( + make_holman_service, tap_timer, listener, kwargs, missing +): + resolve(tap_timer, make_holman_service(**kwargs)) + assert listener.events == [ + ("connect_failed", f"Holman GATT characteristic {missing} missing") + ] + + +def test_services_resolved_missing_characteristic_without_listener( + make_holman_service, tap_timer +): + resolve(tap_timer, make_holman_service(manual=False)) # must not raise + + +@pytest.mark.parametrize( + ("state", "is_on"), [(b"\x00\x01", True), (b"\x01\x00", False)] +) +def test_is_on_reads_last_state_byte(make_holman_service, tap_timer, state, is_on): + resolve(tap_timer, make_holman_service(state=state)) + assert tap_timer.is_on is is_on + + +@pytest.mark.parametrize( + ("runtime", "minutes"), [(1, 1), (30, 30), (255, 255), (600, 255)] +) +def test_start_writes_runtime_capped_at_255( + make_holman_service, tap_timer, runtime, minutes +): + resolve(tap_timer, make_holman_service()) + tap_timer.start(runtime=runtime) + assert manual_char(tap_timer).written == [bytes([0x01, 0x00, 0x00, minutes])] + + +def test_stop_writes_off(make_holman_service, tap_timer): + resolve(tap_timer, make_holman_service()) + tap_timer.stop() + assert manual_char(tap_timer).written == [bytes(4)] + + +def test_start_and_stop_before_connect_are_noops(tap_timer): + tap_timer.start() + tap_timer.stop() + + +def test_connect_and_disconnect_notify_listener(tap_timer, listener): + tap_timer.connect() + tap_timer.disconnect() + tap_timer.disconnect_succeeded() + assert listener.events == [ + "started_connecting", + "started_disconnecting", + "disconnect_succeeded", + ] + assert tap_timer.connect_calls == 1 + assert tap_timer.disconnect_calls == 1 + + +def test_connect_failed_notifies_listener(tap_timer, listener): + tap_timer.connect_failed(Exception("boom")) + assert listener.events == [("connect_failed", "boom")] + + +def test_battery_level_not_decoded(tap_timer): + assert tap_timer.battery_level() is None diff --git a/tests/test_tap_timer_manager.py b/tests/test_tap_timer_manager.py new file mode 100644 index 0000000..e5a31ac --- /dev/null +++ b/tests/test_tap_timer_manager.py @@ -0,0 +1,41 @@ +import holman + +MAC = "AA:BB:CC:DD:EE:FF" + + +class RecordingManagerListener(holman.TapTimerManagerListener): + def __init__(self): + self.discovered = [] + + def tap_timer_discovered(self, tap_timer): + self.discovered.append(tap_timer) + + +def test_start_discovery_filters_on_holman_services(manager): + manager.start_discovery() + assert manager.discovery_uuids == holman.TapTimer.SERVICE_UUIDS + + +def test_make_device_accepts_tap_timer_alias(manager): + manager.aliases[MAC] = "Tap Timer" + device = manager.make_device(MAC) + assert isinstance(device, holman.TapTimer) + assert device.mac_address == MAC + + +def test_make_device_rejects_other_alias(manager): + manager.aliases[MAC] = "Some Speaker" + assert manager.make_device(MAC) is None + + +def test_device_discovered_notifies_once_per_device(manager): + manager.listener = RecordingManagerListener() + tap_timer = holman.TapTimer(mac_address=MAC, manager=manager) + manager.device_discovered(tap_timer) + manager.device_discovered(tap_timer) + assert manager.listener.discovered == [tap_timer] + assert manager.discovered_tap_timers == {MAC: tap_timer} + + +def test_device_discovered_without_listener(manager): + manager.device_discovered(holman.TapTimer(mac_address=MAC, manager=manager)) diff --git a/typings/gatt/__init__.pyi b/typings/gatt/__init__.pyi new file mode 100644 index 0000000..16844f0 --- /dev/null +++ b/typings/gatt/__init__.pyi @@ -0,0 +1,6 @@ +# gatt picks its backend with a runtime platform.system() check, which type +# checkers can't narrow. This SDK is Linux-only, so resolve to the BlueZ backend. +from gatt.gatt_linux import Characteristic as Characteristic +from gatt.gatt_linux import Device as Device +from gatt.gatt_linux import DeviceManager as DeviceManager +from gatt.gatt_linux import Service as Service diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..eb6965e --- /dev/null +++ b/uv.lock @@ -0,0 +1,278 @@ +version = 1 +revision = 5 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "gatt" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/d0/d66154053d5b47996731d80ee66f65bdf7b790258addc0b6a5f50bcc3579/gatt-0.2.7.tar.gz", hash = "sha256:626d9de24a178b6eaff78c31b0bd29f962681da7caf18eb20363f6288d014e3a", size = 7306, upload-time = "2017-11-21T17:04:14.904Z" } + +[[package]] +name = "holman" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "gatt" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyrefly" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "gatt", specifier = ">=0.2.7" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pyrefly", specifier = ">=1.3" }, + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.17" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[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 = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyrefly" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/78/b6ec12acb586606618c71ebe1008af59e1ca50e667895f1946fa7b656619/pyrefly-1.3.2.tar.gz", hash = "sha256:30a68ff5429a1546ec08a2d913dfe442c9553a2940d274de18833d8eda939b5e", size = 6679591, upload-time = "2026-09-28T22:10:06.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/01/144a78839822abad55628881bcb45f8173e6d8b87976897c9105884b5a6b/pyrefly-1.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:626dbaee16ae1169a10a4ba3293583b5ab0320102f35efaad9e03457a2fc646c", size = 15088488, upload-time = "2026-09-28T22:09:40.743Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/599756bd42e6533ee9261ebd288f846ab453b00debaef2b4789893967809/pyrefly-1.3.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d5fc704dbf0e990dc4f15cb1ead64f1a325ae27383a50c76cfcaf0387f926bb", size = 14429996, upload-time = "2026-09-28T22:09:43.316Z" }, + { url = "https://files.pythonhosted.org/packages/90/0c/4b0678889136b1514ecd25cec11974d5bfdaeaaf9f5fe8a766ca45ec4ab5/pyrefly-1.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89dbe8a33e735fa96023c33ce5dfd81af9b0bdd2ee771e8d69b2ebffc7e0fbc7", size = 14871810, upload-time = "2026-09-28T22:09:45.42Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/a96236391c6bb0d8b50af85e0ea6c0c56206f739931ac3031f9e000325ee/pyrefly-1.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ec0c31715d48bff279f548b85a93b5bc43d2c7bd1492a8cd703a9185cb089e5", size = 16113462, upload-time = "2026-09-28T22:09:47.619Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/d4d0966aa7008ebd8481a6d05aa53202ad40c9597705eeee87ba6e0e2025/pyrefly-1.3.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b3de468bed691e40dc6271284c8dcc05e649d7ed45280ef3d07332741efe34c", size = 16043341, upload-time = "2026-09-28T22:09:50.083Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d1/52d9ea1b7048c7316d9ff7594c170b1c06d37547a75d140c8deea7b9ddfd/pyrefly-1.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb94365fe5f854cb4fe82f600c4e2453160d328610b12981004851ee0b6e8aeb", size = 15437183, upload-time = "2026-09-28T22:09:52.56Z" }, + { url = "https://files.pythonhosted.org/packages/08/ec/24f83e4a397fa9d5c416bb4d378ee1bb18fe48bdd5698fe541636f5352f3/pyrefly-1.3.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f730d2e515ac25827fc68f343a7aa02b1072e0062df6a430346a4ffdfb5db2f", size = 14892976, upload-time = "2026-09-28T22:09:54.916Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/a473f8e9ec31ba0682ebe44f4e51167a22a4cbdc8bfd87365b9aca23be20/pyrefly-1.3.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:60f440e1626bd5cc4a41ee90cecba80796ed01ebd6c15d9f933efb8d07ffe2b8", size = 15469689, upload-time = "2026-09-28T22:09:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/d0/df/779092365e8e469fc8e7349e1c26c35b8a73df9e6be80a00265fc26a2b2b/pyrefly-1.3.2-py3-none-win32.whl", hash = "sha256:b31684189de44a25a5bf00bdf3ad16be44a7b14654702d6018bce1043357202c", size = 14135550, upload-time = "2026-09-28T22:09:59.757Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/466333e7f2891f8c357b5c226559c39ba8dde4f90be09189ecc771a086f5/pyrefly-1.3.2-py3-none-win_amd64.whl", hash = "sha256:693960cdb247d1f76b9e09b3f2554dae20034f7d442ccd4e9b3e8962a3622363", size = 15067523, upload-time = "2026-09-28T22:10:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/18/32/ee66df92c692c0686d82c9ecef3876ccb0386aa318bf507016f0121a8f78/pyrefly-1.3.2-py3-none-win_arm64.whl", hash = "sha256:f7424b21331670522980ff13e08bd143659879a4d92f4832282c7d7c41a9b5d6", size = 14348271, upload-time = "2026-09-28T22:10:04.253Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.1", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]