diff --git a/.github/workflows/harp.yml b/.github/workflows/harp.yml new file mode 100644 index 0000000..74ad5a5 --- /dev/null +++ b/.github/workflows/harp.yml @@ -0,0 +1,144 @@ +name: harp + +on: + pull_request: + push: + branches: + - main + release: + types: [published] + workflow_dispatch: + +jobs: + # ---- Tests ---- + tests: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.11", "3.12", "3.13"] + fail-fast: false + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + + - name: Install python dependencies + run: uv sync --group dev --python ${{ matrix.python-version }} + + - name: Run codespell + run: uv run codespell + + - name: Run ruff format + run: uv run ruff format --check + + - name: Run ruff check + run: uv run ruff check + + - name: Run pyright + run: uv run pyright + + - name: Run pytest + run: uv run pytest --cov harp + + - name: Build + run: uv build --all-packages + + # ---- Release build ---- + build-release: + runs-on: ubuntu-latest + name: Build release distributions + needs: tests + if: github.event_name == 'release' && github.event.action == 'published' + + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + + - name: Build + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ github.event.release.tag_name }} + run: uv build --all-packages + + - name: Verify setuptools-scm applied the release tag + shell: bash + run: | + ls -1 dist/ + if ls dist/*-0.0.0.tar.gz >/dev/null 2>&1; then + echo "::error::Built version 0.0.0, so setuptools-scm did not apply the tag. Check that every pyproject.toml still declares a tool.setuptools_scm table." + exit 1 + fi + + - name: Remove internal-only packages from dist + shell: bash + run: rm -f dist/harp_benchmarks-* + + - name: Upload wheels as artifact + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + + # ---- Publish to PyPI ---- + publish-to-pypi: + runs-on: ubuntu-latest + name: Publish to PyPI + needs: build-release + environment: pypi + permissions: + # uv publish exchanges this token with PyPI trusted publishing. + id-token: write + # action-gh-release attaches the distributions to the release. + contents: write + steps: + - name: Download wheels artifact + uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + + - uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + + - name: Publish to PyPI + run: uv publish + + - name: Upload wheels to GitHub release + uses: softprops/action-gh-release@v3 + with: + files: dist/* + + # ---- Docs ---- + build-docs: + name: Build and deploy documentation to GitHub Pages + runs-on: ubuntu-latest + needs: build-release + if: github.event_name == 'release' && !github.event.release.prerelease + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --group docs + + - name: Configure Git user + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Build & Deploy docs + run: uv run mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index f60ee92..691e07d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,180 @@ -.idea/ -pyharp.egg-info/ -.python-version -__pycache__ -tests/.pytest_cache -**/*.bin \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# vscode +.vscode/ + +# Internal benchmark artifacts (harp-benchmarks) +/benchmark/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..42dc33b --- /dev/null +++ b/.python-version @@ -0,0 +1,3 @@ +3.13 +3.12 +3.11 diff --git a/LICENSE b/LICENSE index 86b9923..2ac2366 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,8 @@ MIT License Copyright (c) 2020 OEPS & Filipe Carvalho +Copyright (c) 2025 Hardware and Software Platform, Champalimaud Foundation +Copyright (c) harp-tech and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4b18b2d..9b35717 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,88 @@ +

+ Harp logo +

+# harp -# pyharp +This project includes four main packages: -Harp implementation of the Harp protocol. + - **harp-protocol**: Provides the core protocol definitions and utilities for the Harp protocol. + See [Protocol API Documentation](https://harp-tech.org/pyharp/api/protocol) for details. -## Edit the code + - **harp-serial**: Implements serial communication functionalities for generic Harp devices. + See [Serial API Documentation](https://harp-tech.org/pyharp/api/serial) for more information. -Each Python user has is own very dear IDE for editing. Here, we are leaving instructions on how to edit this code using pyCharm, Anaconda and Poetry. + - **harp-device**: Implements the transport-agnostic `Device` interface, the common register map, and the shared registers and enums. + See [Device API Documentation](https://harp-tech.org/pyharp/api/device) for details. -The instructions are for beginner. Most of the users can just skip them. + - **harp-data**: Parses register binary dumps into pandas DataFrames. + See [Data API Documentation](https://harp-tech.org/pyharp/api/data) for more information. -This was tested on a Windows machine, but should be similar to other systems. +## Installation +All packages are published to PyPI. The `harp` package is a metadata package with no code of +its own — it just depends on the four packages above, so it's the easiest way to get everything: -### 1. Install PyCHarm -**PyCharm** can be download from [here](https://www.jetbrains.com/pycharm/download/). The Community version is enough. -Download and install it. +```sh +pip install harp +``` -### 2. Install Anaconda +```sh +uv add harp +``` -**Anaconda** can be found [here](https://www.anaconda.com/products/individual). -Download the version according to your computer and install it. -- Unselect **Add Anaconda to the system PATH environment variable** -- Select ** Register Anaconda as the system Pyhton** +If you only need part of the stack (e.g. you're parsing offline data dumps and don't need serial +I/O), install just the packages you need — each one only pulls in what it actually depends on: + +| Package | Provides | Depends on | +| --- | --- | --- | +| `harp-protocol` | Core protocol types: registers, messages, payload parsing | — | +| `harp-device` | Transport-agnostic `Device` class, common register map | `harp-protocol` | +| `harp-serial` | Serial (COM/tty) transport for `Device` | `harp-protocol`, `harp-device` | +| `harp-data` | Parse register binary dumps into pandas DataFrames | `harp-protocol` | + +```sh +pip install harp-protocol +pip install harp-device +pip install harp-serial +pip install harp-data +``` -It's suggested to reboot your computer at this point +`harp-benchmarks` (under `src/packages/`) is internal-only and is never published to PyPI. -### 3. Install Poetry +## Contributing -Open the **Command Prompt** and execute the next command: -``` -curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -``` +harp is a [uv workspace](https://docs.astral.sh/uv/concepts/workspaces/): every package under +`src/packages/` is its own distribution, plus the root `harp` metadata package. Contributions are +welcome — please open an issue or PR. -### 4. Install pyharp +Clone the repo and install everything (all workspace packages, editable, plus test/lint tooling) +with the `dev` dependency group: -Open **Anaconda**, navigate to the repository folder and execute the next commands: -``` -poetry install -poetry env info +```sh +uv sync --group dev ``` -The second comand will reply with a **Path:**. -Select and copy this path. +Before opening a PR, run the same checks CI runs: -### 5. Using PyCharm to edit the code +```sh +uv run ruff format --check # formatting +uv run ruff check # lint +uv run ty check # type checking +uv run codespell # spelling +uv run pytest --cov harp # tests +``` -1. Open **PyCharm** :) -2. Go to File -> Open, select the repository folder, and click **OK** -3. Go to File -> Settings -> Project:pyharp -> Project Interpreter -3.1 Click in the gear in front of the Project Interpreter: and select **Add...** -3.2 On Virtualenv Environment, chose Existing environment -3.3 Select **python.exe** on the folder Scripts under the path copied from the _poetry env info_ command -3.4 Click **OK** and **OK** +Adding a new package? Drop it under `src/packages//` with its own `pyproject.toml`, add it +to `[tool.uv.sources]` in the root `pyproject.toml`, and (if it should ship as part of `harp`) add +it to the root package's `dependencies` too. -You are ready to go! +## Building the documentation -### 6. Test the code +Install the docs dependency group and run mkdocs through uv: -Under **PyCharm**, Open one of the examples from the folder _examples_ (the _get_info.py_ is generic, so it's a good option) and update the COMx to your COM number. -Right-click on top of the file and chose option _Run 'get_info.py_. You should read something like this in the console: -``` -Device info: -* Who am I: (2080) IblBehavior -* HW version: 1.0 -* Assembly version: 0 -* HARP version: 1.6 -* Firmware version: 1.0 -* Device user name: IBL_rig_0 +```sh +uv sync --group docs --group dev +uv run mkdocs serve # live-reloading local preview +uv run mkdocs build # static site in ./site ``` diff --git a/docs/api/data.md b/docs/api/data.md new file mode 100644 index 0000000..b79926a --- /dev/null +++ b/docs/api/data.md @@ -0,0 +1,6 @@ +{% include-markdown "../../src/packages/harp-data/README.md" %} + +--- + +::: harp.data.parse_to_dataframe +::: harp.data.payload_to_dataframe diff --git a/docs/api/device.md b/docs/api/device.md new file mode 100644 index 0000000..dfcade9 --- /dev/null +++ b/docs/api/device.md @@ -0,0 +1,15 @@ +{% include-markdown "../../src/packages/harp-device/README.md" %} + +--- + +::: harp.device.Device +::: harp.device.HarpFramer +::: harp.device.ITransport +::: harp.device.TransportError +::: harp.device.REGISTER_MAP +::: harp.device.OperationControl +::: harp.device.OperationMode +::: harp.device.ResetDevice +::: harp.device.ResetFlags +::: harp.device.ClockConfiguration +::: harp.device.ClockConfigurationFlags diff --git a/docs/api/protocol.md b/docs/api/protocol.md new file mode 100644 index 0000000..0a43ce2 --- /dev/null +++ b/docs/api/protocol.md @@ -0,0 +1,14 @@ +{% include-markdown "../../src/packages/harp-protocol/README.md" %} + +--- + +::: harp.protocol.MessageType +::: harp.protocol.PayloadType +::: harp.protocol.HarpMessage +::: harp.protocol.RegisterBase +::: harp.protocol.StructPayload +::: harp.protocol.AnonymousPayload +::: harp.protocol.Field +::: harp.protocol.GroupMask +::: harp.protocol.BitMask +::: harp.protocol.Converter diff --git a/docs/api/serial.md b/docs/api/serial.md new file mode 100644 index 0000000..44bfe72 --- /dev/null +++ b/docs/api/serial.md @@ -0,0 +1,6 @@ +{% include-markdown "../../src/packages/harp-serial/README.md" %} + +--- + +::: harp.serial.SerialTransport +::: harp.serial.open_serial_device diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png new file mode 100644 index 0000000..49c6fae Binary files /dev/null and b/docs/assets/favicon.png differ diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..ed0e7bb --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + diff --git a/docs/examples/get_info/get_info.md b/docs/examples/get_info/get_info.md new file mode 100644 index 0000000..0ebfe38 --- /dev/null +++ b/docs/examples/get_info/get_info.md @@ -0,0 +1,12 @@ +# Getting Device Info + +This example demonstrates how to connect to a Harp device, read its info and dump the device's registers. + +!!! warning + Don't forget to change the `SERIAL_PORT` to the one that corresponds to your device! The `SERIAL_PORT` must be denoted as `/dev/ttyUSBx` in Linux and `COMx` in Windows, where `x` is the number of the serial port. + + +```python +[](./get_info.py) +``` + diff --git a/docs/examples/get_info/get_info.py b/docs/examples/get_info/get_info.py new file mode 100755 index 0000000..cc0c350 --- /dev/null +++ b/docs/examples/get_info/get_info.py @@ -0,0 +1,14 @@ +from harp.device import REGISTER_MAP, Device, WhoAmI +from harp.serial import open_serial_device + +SERIAL_PORT = "/dev/ttyUSB0" # or "COMx" in Windows ("x" is the number of the serial port) + +# Open a serial connection to the device (closed automatically on exit). +with open_serial_device(Device, port=SERIAL_PORT) as device: + # Identify the device. + print("WhoAmI:", device.read(WhoAmI).parsed) + + # Dump every core register. + for address, register in sorted(REGISTER_MAP.items()): + reply = device.read(register) + print(f"{register.__name__:24s} (addr {address:2d}) = {reply.parsed}") diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..52e4245 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,9 @@ +# Examples + +This section contains some examples to help you get started with `harp`. + +Here's the complete list of available examples: + +- [Getting Device Info](./get_info/get_info.md) - connect to a Harp device and read its information. +- [Read and Write from Registers](./read_and_write_from_registers/read_and_write_from_registers.md) - connect to a Harp device and read and write its registers. +- [Reading Data into a DataFrame](./read_data_to_dataframe/read_data_to_dataframe.md) - load a register's binary data file into a pandas DataFrame with `harp.data`. diff --git a/docs/examples/read_and_write_from_registers/read_and_write_from_registers.md b/docs/examples/read_and_write_from_registers/read_and_write_from_registers.md new file mode 100644 index 0000000..d67f118 --- /dev/null +++ b/docs/examples/read_and_write_from_registers/read_and_write_from_registers.md @@ -0,0 +1,12 @@ +# Read and Write from Registers + +This example demonstrates how to read and write from registers, using the core registers exposed by `harp.device`. Device-specific registers (e.g. a [Harp Behavior](https://harp-tech.org/api/Harp.Behavior.html)'s digital I/O) are used the same way — pass that device's register classes to `read`/`write`. + +!!! warning + Don't forget to change the `SERIAL_PORT` to the one that corresponds to your device! The `SERIAL_PORT` must be denoted as `/dev/ttyUSBx` in Linux and `COMx` in Windows, where `x` is the number of the serial port. + + +```python +[](./read_and_write_from_registers.py) +``` + diff --git a/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py b/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py new file mode 100755 index 0000000..10371d3 --- /dev/null +++ b/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py @@ -0,0 +1,17 @@ +from harp.device import Device, OperationControl, OperationControlPayload, OperationMode, WhoAmI +from harp.serial import open_serial_device + +SERIAL_PORT = "/dev/ttyUSB0" # or "COMx" in Windows ("x" is the number of the serial port) + +with open_serial_device(Device, port=SERIAL_PORT) as device: + # Read a scalar register. + print("WhoAmI:", device.read(WhoAmI).parsed) + + # Read a structured register and inspect a field. + control = device.read(OperationControl).parsed + print("operation_mode before:", control.operation_mode) + + # Write the register, then read it back to confirm the change. + device.write(OperationControl, OperationControlPayload(operation_mode=OperationMode.ACTIVE)) + control = device.read(OperationControl).parsed + print("operation_mode after:", control.operation_mode) diff --git a/docs/examples/read_data_to_dataframe/read_data_to_dataframe.md b/docs/examples/read_data_to_dataframe/read_data_to_dataframe.md new file mode 100644 index 0000000..a205b79 --- /dev/null +++ b/docs/examples/read_data_to_dataframe/read_data_to_dataframe.md @@ -0,0 +1,11 @@ +# Reading Data into a DataFrame + +This example demonstrates how to load a Harp register's binary data file into a +pandas DataFrame using `harp.data`. The register definition tells `parse_to_dataframe` +how to decode each frame, so you get named columns (and decoded enums) for free. + + +```python +[](./read_data_to_dataframe.py) +``` + diff --git a/docs/examples/read_data_to_dataframe/read_data_to_dataframe.py b/docs/examples/read_data_to_dataframe/read_data_to_dataframe.py new file mode 100644 index 0000000..fda5a2d --- /dev/null +++ b/docs/examples/read_data_to_dataframe/read_data_to_dataframe.py @@ -0,0 +1,11 @@ +from harp.data import parse_to_dataframe +from harp.device import OperationControl + +# Parse a register's binary dump into a pandas DataFrame — one row per frame, +# one column per field, plus a leading "timestamp" column. +df = parse_to_dataframe(OperationControl, "OperationControl.bin", timestamp=True) +print(df.head()) + +# `parse_to_dataframe` also accepts raw bytes or an open binary file object: +with open("OperationControl.bin", "rb") as f: + df = parse_to_dataframe(OperationControl, f) diff --git a/docs/examples/subscribing_to_events/subscribing_to_events.md b/docs/examples/subscribing_to_events/subscribing_to_events.md new file mode 100644 index 0000000..796c4b2 --- /dev/null +++ b/docs/examples/subscribing_to_events/subscribing_to_events.md @@ -0,0 +1,17 @@ +# Subscribing to Events + +This example demonstrates how to react to messages pushed by the device — e.g. unsolicited `Event` messages — without polling, using two subscription styles: + +- `device.subscribe(register, handler)` — the handler receives a typed, parsed `ParsedHarpMessage` for a single register. +- `device.subscribe_all(handler)` — a catch-all handler that receives the raw `HarpMessage` for every register. + +Handlers run on a dedicated event thread, so they never block `read()`/`write()`. Both methods return a `Subscription`; call `.unsubscribe()` (or use it as a context manager) to stop receiving events. + +!!! warning + Don't forget to change the `SERIAL_PORT` to the one that corresponds to your device! The `SERIAL_PORT` must be denoted as `/dev/ttyUSBx` in Linux and `COMx` in Windows, where `x` is the number of the serial port. + + +```python +[](./subscribing_to_events.py) +``` + diff --git a/docs/examples/subscribing_to_events/subscribing_to_events.py b/docs/examples/subscribing_to_events/subscribing_to_events.py new file mode 100644 index 0000000..c649514 --- /dev/null +++ b/docs/examples/subscribing_to_events/subscribing_to_events.py @@ -0,0 +1,45 @@ +from harp.device import ( + REGISTER_MAP, + Device, + OperationControl, + OperationControlPayload, + OperationMode, + TimestampSeconds, +) +from harp.protocol import HarpMessage, ParsedHarpMessage +from harp.serial import open_serial_device + +SERIAL_PORT = "/dev/ttyUSB0" # or "COMx" in Windows ("x" is the number of the serial port) + + +def print_timestamp(msg: ParsedHarpMessage[float]) -> None: + print(f"[timestamp] {msg.timestamp:.6f} {msg.parsed}") + + +def print_any_event(msg: HarpMessage) -> None: + register = REGISTER_MAP.get(msg.address, None) + value = register.parse(msg) if register is not None else msg.payload.hex() + print(f"[{msg.address}] {msg.timestamp:.6f} {msg.message_type.name:<5s} {value}") + + +with open_serial_device(Device, port=SERIAL_PORT) as device: + # Subscribe to a single, typed register: the handler receives a parsed payload. + timestamp_subscription = device.subscribe(TimestampSeconds, print_timestamp) + + # Subscribe to every register at once: the handler receives the raw message. + device.subscribe_all(print_any_event) + + device.write( + OperationControl, + OperationControlPayload( + operation_mode=OperationMode.ACTIVE, + dump_registers=True, + heartbeat=True, + mute_replies=False, + operation_led=True, + visual_indicators=True, + ), + ) + + input("Listening for events. Press Enter to stop.\n") + timestamp_subscription.unsubscribe() diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..96d83c6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +{% include-markdown "../README.md" %} \ No newline at end of file diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..b417550 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,9 @@ +:root > * { + --md-primary-fg-color: #009DE1; + --md-primary-fg-color--light: #009DE1; + --md-primary-fg-color--dark: #009DE1; +} + +.md-nav__title { + display: none; +} diff --git a/examples/check_device_id.py b/examples/check_device_id.py deleted file mode 100644 index b118b0e..0000000 --- a/examples/check_device_id.py +++ /dev/null @@ -1,31 +0,0 @@ -from pyharp.device import Device -from pyharp.messages import HarpMessage -from pyharp.messages import MessageType -from struct import * - - -# ON THIS EXAMPLE -# -# This code check if the device at COMx is the expected device. -# The device ID used is the 2080, the IblBehavior - - -# Open the device -device = Device("COM95") # Open serial connection - -# Get some of the device's parameters -device_id = device.WHO_AM_I # Get device's ID -device_id_description = device.WHO_AM_I_DEVICE # Get device's user name -device_user_name = device.DEVICE_NAME # Get device's user name - -# Check if we are dealing with the correct device -if device_id == 2080: - print("Correct device was found!") - print(f"Device's ID: {device_id}") - print(f"Device's name: {device_id_description}") - print(f"Device's user name: {device_user_name}") -else: - print("Device not correct or is not a Harp device!") - -# Close connection -device.disconnect() diff --git a/examples/get_info.py b/examples/get_info.py deleted file mode 100644 index fca840c..0000000 --- a/examples/get_info.py +++ /dev/null @@ -1,32 +0,0 @@ -from pyharp.device import Device -from pyharp.messages import HarpMessage -from pyharp.messages import MessageType -from struct import * - - -# ON THIS EXAMPLE -# -# This code opens the connection with the device and displays the information -# Also saves device's information into variables - - -# Open the device and print the info on screen -device = Device("COM95", "ibl.bin") # Open serial connection and save communication to a file -device.info() # Display device's info on screen - -# Get some of the device's parameters -device_id = device.WHO_AM_I # Get device's ID -device_id_description = device.WHO_AM_I_DEVICE # Get device's user name -device_user_name = device.DEVICE_NAME # Get device's user name - -# Get versions -device_fw_h = device.FIRMWARE_VERSION_H # Get device's firmware version -device_fw_l = device.FIRMWARE_VERSION_L # Get device's firmware version -device_hw_h = device.HW_VERSION_H # Get device's hardware version -device_hw_l = device.HW_VERSION_L # Get device's hardware version -device_harp_h = device.HARP_VERSION_H # Get device's harp core version -device_harp_l = device.HARP_VERSION_L # Get device's harp core version -device_assembly = device.ASSEMBLY_VERSION # Get device's assembly version - -# Close connection -device.disconnect() \ No newline at end of file diff --git a/examples/write_and_read_from_registers.py b/examples/write_and_read_from_registers.py deleted file mode 100644 index ccde127..0000000 --- a/examples/write_and_read_from_registers.py +++ /dev/null @@ -1,37 +0,0 @@ -from pyharp.device import Device -from pyharp.messages import HarpMessage -from pyharp.messages import MessageType -from struct import * - - -# ON THIS EXAMPLE -# -# This code opens the connection with the device and update content on a register -# It uses register address 42, which stores the analog sensor's higher threshold in the IBLBehavior device -# This register is unsigned with 16 bits (U16) - - -# Open the device and print the info on screen -device = Device("COM95", "ibl.bin") # Open serial connection and save communication to a file - -# Read current analog sensor's higher threshold (ANA_SENSOR_TH0_HIGH) at address 42 -analog_threshold_h = device.send(HarpMessage.ReadU16(42).frame).payload_as_int() -print(f"Analog sensor's higher threshold: {analog_threshold_h}") - -# Increase current analog sensor's higher threshold by one unit -device.send(HarpMessage.WriteU16(42, analog_threshold_h+1).frame) - -# Check if the register was well written -analog_threshold_h = device.send(HarpMessage.ReadU16(42).frame).payload_as_int() -print(f"Analog sensor's higher threshold: {analog_threshold_h}") - -# Read 10 samples of the analog sensor and display the values -# The value is at register STREAM[0], address 33 -analog_sensor = [] -for x in range(10): - value = device.send(HarpMessage.ReadS16(33).frame).payload_as_int() - analog_sensor.append(value & 0xffff) -print(f"Analog sensor's values: {analog_sensor}") - -# Close connection -device.disconnect() \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..48b3d6b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,92 @@ +site_name: harp +repo_url: https://github.com/harp-tech/pyharp +repo_name: "harp-tech/pyharp" +copyright: Copyright (c) harp-tech and Contributors + +plugins: + - search + - autorefs + - codeinclude + - include-markdown + - mkdocstrings: + handlers: + python: + options: + docstring_style: numpy + show_root_heading: true + show_submodules: true + show_source: false + extensions: + - griffe_fieldz + - git-authors + + +markdown_extensions: + - abbr + - attr_list + - admonition + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - toc: + permalink: "#" + +theme: + name: material + icon: + repo: fontawesome/brands/github + logo: assets/logo.svg + favicon: assets/favicon.png + features: + - content.tooltips + - toc.follow + - content.code.copy + - navigation.sections + - navigation.indexes + - navigation.footer + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: light-blue + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: light-blue + toggle: + icon: material/weather-night + name: Switch to system preference + +nav: + - Home: index.md + - Examples: + - examples/index.md + - Getting Device Info: examples/get_info/get_info.md + - Read and Write from Registers: examples/read_and_write_from_registers/read_and_write_from_registers.md + - Reading Data into a DataFrame: examples/read_data_to_dataframe/read_data_to_dataframe.md + - Subscribing to Events: examples/subscribing_to_events/subscribing_to_events.md + - API: + - Protocol: api/protocol.md + - Serial: api/serial.md + - Device: api/device.md + - Data: api/data.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/harp-tech/pyharp + +extra_css: +- stylesheets/extra.css diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 779054c..0000000 --- a/mypy.ini +++ /dev/null @@ -1,14 +0,0 @@ - -[mypy] -#strict = True -namespace_packages = True -warn_redundant_casts = True -warn_unused_ignores = True -disallow_subclassing_any = True -disallow_untyped_calls = True -#disallow_untyped_defs = True -#check_untyped_defs = True -warn_return_any = True -no_implicit_optional = False -strict_optional = True -ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 6e3d0a4..0000000 --- a/poetry.lock +++ /dev/null @@ -1,415 +0,0 @@ -[[package]] -category = "main" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -name = "appdirs" -optional = false -python-versions = "*" -version = "1.4.4" - -[[package]] -category = "dev" -description = "Atomic file writes." -marker = "sys_platform == \"win32\"" -name = "atomicwrites" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.4.0" - -[[package]] -category = "main" -description = "Classes Without Boilerplate" -name = "attrs" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" - -[package.extras] -azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] -dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] -docs = ["sphinx", "zope.interface"] -tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] - -[[package]] -category = "main" -description = "The uncompromising code formatter." -name = "black" -optional = false -python-versions = ">=3.6" -version = "19.10b0" - -[package.dependencies] -appdirs = "*" -attrs = ">=18.1.0" -click = ">=6.5" -pathspec = ">=0.6,<1" -regex = "*" -toml = ">=0.9.4" -typed-ast = ">=1.4.0" - -[package.extras] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] - -[[package]] -category = "main" -description = "Composable command line interface toolkit" -name = "click" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.2" - -[[package]] -category = "dev" -description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\"" -name = "colorama" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" - -[[package]] -category = "dev" -description = "Read metadata from Python packages" -marker = "python_version < \"3.8\"" -name = "importlib-metadata" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] - -[[package]] -category = "dev" -description = "More routines for operating on iterables, beyond itertools" -name = "more-itertools" -optional = false -python-versions = ">=3.5" -version = "8.4.0" - -[[package]] -category = "dev" -description = "Optional static typing for Python" -name = "mypy" -optional = false -python-versions = ">=3.5" -version = "0.782" - -[package.dependencies] -mypy-extensions = ">=0.4.3,<0.5.0" -typed-ast = ">=1.4.0,<1.5.0" -typing-extensions = ">=3.7.4" - -[package.extras] -dmypy = ["psutil (>=4.0)"] - -[[package]] -category = "dev" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -name = "mypy-extensions" -optional = false -python-versions = "*" -version = "0.4.3" - -[[package]] -category = "dev" -description = "Core utilities for Python packages" -name = "packaging" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.4" - -[package.dependencies] -pyparsing = ">=2.0.2" -six = "*" - -[[package]] -category = "main" -description = "Utility library for gitignore style pattern matching of file paths." -name = "pathspec" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.8.0" - -[[package]] -category = "dev" -description = "plugin and hook calling mechanisms for python" -name = "pluggy" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.1" - -[package.dependencies] -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - -[package.extras] -dev = ["pre-commit", "tox"] - -[[package]] -category = "dev" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -name = "py" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.9.0" - -[[package]] -category = "dev" -description = "Python parsing module" -name = "pyparsing" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" - -[[package]] -category = "main" -description = "Python Serial Port Extension" -name = "pyserial" -optional = false -python-versions = "*" -version = "3.4" - -[[package]] -category = "dev" -description = "pytest: simple powerful testing with Python" -name = "pytest" -optional = false -python-versions = ">=3.5" -version = "5.4.3" - -[package.dependencies] -atomicwrites = ">=1.0" -attrs = ">=17.4.0" -colorama = "*" -more-itertools = ">=4.0.0" -packaging = "*" -pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -wcwidth = "*" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - -[package.extras] -checkqa-mypy = ["mypy (v0.761)"] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] - -[[package]] -category = "main" -description = "Alternative regular expression module, to replace re." -name = "regex" -optional = false -python-versions = "*" -version = "2020.7.14" - -[[package]] -category = "dev" -description = "Python 2 and 3 compatibility utilities" -name = "six" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.15.0" - -[[package]] -category = "main" -description = "Python Library for Tom's Obvious, Minimal Language" -name = "toml" -optional = false -python-versions = "*" -version = "0.10.1" - -[[package]] -category = "main" -description = "a fork of Python 2 and 3 ast modules with type comment support" -name = "typed-ast" -optional = false -python-versions = "*" -version = "1.4.1" - -[[package]] -category = "dev" -description = "Backported and Experimental Type Hints for Python 3.5+" -name = "typing-extensions" -optional = false -python-versions = "*" -version = "3.7.4.2" - -[[package]] -category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" -name = "wcwidth" -optional = false -python-versions = "*" -version = "0.2.5" - -[[package]] -category = "dev" -description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version < \"3.8\"" -name = "zipp" -optional = false -python-versions = ">=3.6" -version = "3.1.0" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["jaraco.itertools", "func-timeout"] - -[metadata] -content-hash = "e591cefbf7f181c5a3dbb815804404256412aa148b25f12528bbad5dfb31e6e7" -python-versions = "^3.7" - -[metadata.files] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] -atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, -] -attrs = [ - {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, - {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, -] -black = [ - {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, - {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, -] -click = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, -] -colorama = [ - {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, - {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, -] -importlib-metadata = [ - {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, - {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, -] -more-itertools = [ - {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, - {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, -] -mypy = [ - {file = "mypy-0.782-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c"}, - {file = "mypy-0.782-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e"}, - {file = "mypy-0.782-cp35-cp35m-win_amd64.whl", hash = "sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d"}, - {file = "mypy-0.782-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd"}, - {file = "mypy-0.782-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"}, - {file = "mypy-0.782-cp36-cp36m-win_amd64.whl", hash = "sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406"}, - {file = "mypy-0.782-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86"}, - {file = "mypy-0.782-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707"}, - {file = "mypy-0.782-cp37-cp37m-win_amd64.whl", hash = "sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308"}, - {file = "mypy-0.782-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc"}, - {file = "mypy-0.782-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea"}, - {file = "mypy-0.782-cp38-cp38-win_amd64.whl", hash = "sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b"}, - {file = "mypy-0.782-py3-none-any.whl", hash = "sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d"}, - {file = "mypy-0.782.tar.gz", hash = "sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c"}, -] -mypy-extensions = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, -] -packaging = [ - {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, - {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, -] -pathspec = [ - {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, - {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, -] -pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, -] -py = [ - {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, - {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, -] -pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, -] -pyserial = [ - {file = "pyserial-3.4-py2.py3-none-any.whl", hash = "sha256:e0770fadba80c31013896c7e6ef703f72e7834965954a78e71a3049488d4d7d8"}, - {file = "pyserial-3.4.tar.gz", hash = "sha256:6e2d401fdee0eab996cf734e67773a0143b932772ca8b42451440cfed942c627"}, -] -pytest = [ - {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, - {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, -] -regex = [ - {file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"}, - {file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"}, - {file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"}, - {file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"}, - {file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"}, - {file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a"}, - {file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"}, - {file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"}, - {file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"}, -] -six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, -] -toml = [ - {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, - {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, -] -typed-ast = [ - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, - {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, - {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, - {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, - {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, - {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, - {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, - {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, -] -typing-extensions = [ - {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, - {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, - {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, -] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] -zipp = [ - {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, - {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, -] diff --git a/pyharp/__init__.py b/pyharp/__init__.py deleted file mode 100644 index 3dc1f76..0000000 --- a/pyharp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/pyharp/device.py b/pyharp/device.py deleted file mode 100644 index 5e0e64d..0000000 --- a/pyharp/device.py +++ /dev/null @@ -1,200 +0,0 @@ -import serial -from typing import Optional -from pathlib import Path - -from pyharp.messages import HarpMessage, ReplyHarpMessage -from pyharp.messages import CommonRegisters -from pyharp import device_names - - -class Device: - """ - https://github.com/harp-tech/protocol/blob/master/Device%201.0%201.3%2020190207.pdf - """ - - _ser: serial.Serial - _dump_file_path: Path - - WHO_AM_I: int - WHO_AM_I_DEVICE: str - HW_VERSION_H: int - HW_VERSION_L: int - ASSEMBLY_VERSION: int - HARP_VERSION_H: int - HARP_VERSION_L: int - FIRMWARE_VERSION_H: int - FIRMWARE_VERSION_L: int - # TIMESTAMP_SECOND = 0x08 - # TIMESTAMP_MICRO = 0x09 - # OPERATION_CTRL = 0x0A - # RESET_DEV = 0x0B - DEVICE_NAME: str - - def __init__(self, serial_port: str, dump_file_path: Optional[str] = None): - self._serial_port = serial_port - if dump_file_path is None: - self._dump_file_path = None - else: - self._dump_file_path = Path() / dump_file_path - self.connect() - self.load() - - def load(self) -> None: - self.WHO_AM_I = self.read_who_am_i() - self.WHO_AM_I_DEVICE = self.read_who_am_i_device() - self.HW_VERSION_H = self.read_hw_version_h() - self.HW_VERSION_L = self.read_hw_version_l() - self.ASSEMBLY_VERSION = self.read_assembly_version() - self.HARP_VERSION_H = self.read_harp_h_version() - self.HARP_VERSION_L = self.read_harp_l_version() - self.FIRMWARE_VERSION_H = self.read_fw_h_version() - self.FIRMWARE_VERSION_L = self.read_fw_l_version() - self.DEVICE_NAME = self.read_device_name() - - def info(self) -> None: - print("Device info:") - #print(f"* Who am I (ID): {self.WHO_AM_I}") - #print(f"* Who am I (Device): {self.WHO_AM_I_DEVICE}") - print(f"* Who am I: ({self.WHO_AM_I}) {self.WHO_AM_I_DEVICE}") - print(f"* HW version: {self.HW_VERSION_H}.{self.HW_VERSION_L}") - print(f"* Assembly version: {self.ASSEMBLY_VERSION}") - print(f"* HARP version: {self.HARP_VERSION_H}.{self.HARP_VERSION_L}") - print( - f"* Firmware version: {self.FIRMWARE_VERSION_H}.{self.FIRMWARE_VERSION_L}" - ) - print(f"* Device user name: {self.DEVICE_NAME}") - - def read(self): - pass - - def connect(self) -> None: - self._ser = serial.Serial( - self._serial_port, # "/dev/tty.usbserial-A106C8O9" - baudrate=1000000, - timeout=1, - parity=serial.PARITY_NONE, - stopbits=1, - bytesize=8, - rtscts=True, - ) - - def disconnect(self) -> None: - self._ser.close() - - def read_who_am_i(self) -> int: - address = CommonRegisters.WHO_AM_I - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU16(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_who_am_i_device(self) -> str: - address = CommonRegisters.WHO_AM_I - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU16(address).frame, dump=False - ) - - return device_names.get(reply.payload_as_int()) - - def read_hw_version_h(self) -> int: - address = CommonRegisters.HW_VERSION_H - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_hw_version_l(self) -> int: - address = CommonRegisters.HW_VERSION_L - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_assembly_version(self) -> int: - address = CommonRegisters.ASSEMBLY_VERSION - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_harp_h_version(self) -> int: - address = CommonRegisters.HARP_VERSION_H - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_harp_l_version(self) -> int: - address = CommonRegisters.HARP_VERSION_L - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_fw_h_version(self) -> int: - address = CommonRegisters.FIRMWARE_VERSION_H - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_fw_l_version(self) -> int: - address = CommonRegisters.FIRMWARE_VERSION_L - - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_int() - - def read_device_name(self) -> str: - address = CommonRegisters.DEVICE_NAME - - # reply: Optional[bytes] = self.send(HarpMessage.ReadU8(address).frame, 13 + 24) - reply: ReplyHarpMessage = self.send( - HarpMessage.ReadU8(address).frame, dump=False - ) - - return reply.payload_as_string() - - def send(self, message_bytes: bytearray, dump: bool = True) -> ReplyHarpMessage: - - self._ser.write(message_bytes) - - # TODO: handle case where read is None - - message_type = self._ser.read(1)[0] # byte array with only one byte - message_length = self._ser.read(1)[0] - message_content = self._ser.read(message_length) - - frame = bytearray() - frame.append(message_type) - frame.append(message_length) - frame += message_content - - reply: ReplyHarpMessage = HarpMessage.parse(frame) - - if dump: - self._dump_reply(reply.frame) - - return reply - - def _dump_reply(self, reply: bytes): - assert self._dump_file_path is not None - with self._dump_file_path.open(mode="ab") as f: - f.write(reply) diff --git a/pyharp/device_names.py b/pyharp/device_names.py deleted file mode 100644 index e5d8cbd..0000000 --- a/pyharp/device_names.py +++ /dev/null @@ -1,41 +0,0 @@ -def get(value: int) -> str: - if value == 1024: - return "Poke" - elif value == 1040: - return "MultiPwm" - elif value == 1056: - return "Wear" - elif value == 1072: - return "VoltsDrive" - elif value == 1088: - return "LedController" - elif value == 1104: - return "Synchronizer" - elif value == 1121: - return "SimpleAnalogGenerator" - elif value == 1136: - return "Archimedes" - elif value == 1152: - return "ClockSynchronizer" - elif value == 1168: - return "Camera" - elif value == 1184: - return "PyControl" - elif value == 1200: - return "FlyPad" - elif value == 1216: - return "Behavior" - elif value == 1232: - return "LoadCells" - elif value == 1248: - return "AudioSwitch" - elif value == 1264: - return "Rgb" - elif value == 1200: - return "FlyPad" - elif value == 2064: - return "FP3002" - elif value == 2080: - return "IblBehavior" - else: - return "NotSpecified" diff --git a/pyharp/main.py b/pyharp/main.py deleted file mode 100644 index 7197906..0000000 --- a/pyharp/main.py +++ /dev/null @@ -1,9 +0,0 @@ -import serial - - -def main() -> None: - print("hello world") - - -if __name__ == "__main__": - main() diff --git a/pyharp/messages.py b/pyharp/messages.py deleted file mode 100644 index 383ae2e..0000000 --- a/pyharp/messages.py +++ /dev/null @@ -1,395 +0,0 @@ -# from abc import ABC, abstractmethod -from typing import Union, Tuple, Optional - - -class MessageType: - READ: int = 1 - WRITE: int = 2 - EVENT: int = 3 - READ_ERROR: int = 9 - WRITE_ERROR: int = 10 - - -class PayloadType: - isUnsigned: int = 0x00 - isSigned: int = 0x80 - isFloat: int = 0x40 - hasTimestamp: int = 0x10 - - U8 = isUnsigned | 1 # 1 - S8 = isSigned | 1 # 129 - U16 = isUnsigned | 2 # 2 - S16 = isSigned | 2 # 130 - U32 = isUnsigned | 4 - S32 = isSigned | 4 - U64 = isUnsigned | 8 - S64 = isSigned | 8 - Float = isFloat | 4 - Timestamp = hasTimestamp - TimestampedU8 = hasTimestamp | U8 - TimestampedS8 = hasTimestamp | S8 - TimestampedU16 = hasTimestamp | U16 - TimestampedS16 = hasTimestamp | S16 - TimestampedU32 = hasTimestamp | U32 - TimestampedS32 = hasTimestamp | S32 - TimestampedU64 = hasTimestamp | U64 - TimestampedS64 = hasTimestamp | S64 - TimestampedFloat = hasTimestamp | Float - - ALL_UNSIGNED = [U8, U16, U32, TimestampedU8, TimestampedU16] - ALL_SIGNED = [S8, S16, S32, TimestampedS8, TimestampedS16] - - -class CommonRegisters: - WHO_AM_I = 0x00 - HW_VERSION_H = 0x01 - HW_VERSION_L = 0x02 - ASSEMBLY_VERSION = 0x03 - HARP_VERSION_H = 0x04 - HARP_VERSION_L = 0x05 - FIRMWARE_VERSION_H = 0x06 - FIRMWARE_VERSION_L = 0x07 - TIMESTAMP_SECOND = 0x08 - TIMESTAMP_MICRO = 0x09 - OPERATION_CTRL = 0x0A - RESET_DEV = 0x0B - DEVICE_NAME = 0x0C - - -T = Union[int, bytearray] - - -class HarpMessage: - DEFAULT_PORT: int = 255 - _frame: bytearray - - def __init__(self): - self._frame = bytearray() - - def calculate_checksum(self) -> int: - checksum: int = 0 - for i in self.frame: - checksum += i - return checksum & 255 - - @property - def frame(self) -> bytearray: - return self._frame - - @property - def message_type(self) -> int: - return self._frame[0] - - @staticmethod - def ReadU8(address: int) -> "ReadU8HarpMessage": - return ReadU8HarpMessage(address) - - @staticmethod - def ReadS8(address: int) -> "ReadS8HarpMessage": - return ReadS8HarpMessage(address) - - @staticmethod - def ReadS16(address: int) -> "ReadS16HarpMessage": - return ReadS16HarpMessage(address) - - @staticmethod - def ReadU16(address: int) -> "ReadU16HarpMessage": - return ReadU16HarpMessage(address) - - @staticmethod - def WriteU8(address: int, value: int) -> "WriteU8HarpMessage": - return WriteU8HarpMessage(address, value) - - @staticmethod - def WriteS8(address: int, value: int) -> "WriteS8HarpMessage": - return WriteS8HarpMessage(address, value) - - @staticmethod - def WriteS16(address: int, value: int) -> "WriteS16HarpMessage": - return WriteS16HarpMessage(address, value) - - @staticmethod - def WriteU16(address: int, value: int) -> "WriteU16HarpMessage": - return WriteU16HarpMessage(address, value) - - @staticmethod - def parse(frame: bytearray) -> "ReplyHarpMessage": - return ReplyHarpMessage(frame) - - -class ReplyHarpMessage(HarpMessage): - PAYLOAD_START_ADDRESS: int - PAYLOAD_LAST_ADDRESS: int - _message_type: int - _length: int - _address: int - _payload_type: int - _payload: bytes - _checksum: int - - def __init__( - self, frame: bytearray, - ): - """ - - :param payload_type: - :param payload: - :param address: - :param offset: how many bytes more besides the length corresponding to U8 (for example, for U16 it would be offset=1) - """ - - self._frame = frame - - self._message_type = frame[0] - self._length = frame[1] - self._address = frame[2] - self._port = frame[3] - self._payload_type = frame[4] - # TOOO: add timestamp here - self._payload = frame[ - 11:-1 - ] # retrieve all content from 11 (where payload starts) until the checksum (not inclusive) - self._checksum = frame[-1] # last index is the checksum - - # print(f"Type: {self.message_type}") - # print(f"Length: {self.length}") - # print(f"Address: {self.address}") - # print(f"Port: {self.port}") - # print(f"Payload Type: {self.payload_type}") - # print(f"Payload: {self.payload}") - # print(f"Checksum: {self.checksum}") - # print(f"Frame: {self.frame}") - - @property - def frame(self) -> bytearray: - return self._frame - - @property - def message_type(self) -> int: - return self._message_type - - @property - def length(self) -> int: - return self._length - - @property - def address(self) -> int: - return self._address - - @property - def port(self) -> int: - return self._port - - @property - def payload_type(self) -> int: - return self._payload_type - - @property - def payload(self) -> bytes: - return self._payload - - def payload_as_int(self) -> int: - value: int = 0 - if self.payload_type in PayloadType.ALL_UNSIGNED: - value = int.from_bytes(self.payload, byteorder="little", signed=False) - elif self.payload_type in PayloadType.ALL_SIGNED: - value = int.from_bytes(self.payload, byteorder="little", signed=True) - return value - - def payload_as_int_array(self): - pass # TODO: implement this - - def payload_as_string(self) -> str: - return self.payload.decode("utf-8") - - @property - def checksum(self) -> int: - return self._checksum - - -class ReadHarpMessage(HarpMessage): - MESSAGE_TYPE: int = MessageType.READ - _length: int - _address: int - _payload_type: int - _checksum: int - - def __init__(self, payload_type: int, address: int): - self._frame = bytearray() - - self._frame.append(self.MESSAGE_TYPE) - - length: int = 4 - self._frame.append(length) - - self._frame.append(address) - self._frame.append(self.DEFAULT_PORT) - self._frame.append(payload_type) - self._frame.append(self.calculate_checksum()) - - # def calculate_checksum(self) -> int: - # return ( - # self.message_type - # + self.length - # + self.address - # + self.port - # + self.payload_type - # ) & 255 - - @property - def message_type(self) -> int: - return self._frame[0] - - @property - def length(self) -> int: - return self._frame[1] - - @property - def address(self) -> int: - return self._frame[2] - - @property - def port(self) -> int: - return self._frame[3] - - @property - def payload_type(self) -> int: - return self._frame[4] - - @property - def checksum(self) -> int: - return self._frame[5] - - -class ReadU8HarpMessage(ReadHarpMessage): - def __init__(self, address: int): - super().__init__(PayloadType.U8, address) - - -class ReadS8HarpMessage(ReadHarpMessage): - def __init__(self, address: int): - super().__init__(PayloadType.S8, address) - - -class ReadU16HarpMessage(ReadHarpMessage): - def __init__(self, address: int): - super().__init__(PayloadType.U16, address) - - -class ReadS16HarpMessage(ReadHarpMessage): - def __init__(self, address: int): - super().__init__(PayloadType.S16, address) - - -class WriteHarpMessage(HarpMessage): - BASE_LENGTH: int = 5 - MESSAGE_TYPE: int = MessageType.WRITE - _length: int - _address: int - _payload_type: int - _payload: int - _checksum: int - - def __init__( - self, payload_type: int, payload: bytes, address: int, offset: int = 0 - ): - """ - - :param payload_type: - :param payload: - :param address: - :param offset: how many bytes more besides the length corresponding to U8 (for example, for U16 it would be offset=1) - """ - self._frame = bytearray() - - self._frame.append(self.MESSAGE_TYPE) - - self._frame.append(self.BASE_LENGTH + offset) - - self._frame.append(address) - self._frame.append(HarpMessage.DEFAULT_PORT) - self._frame.append(payload_type) - - for i in payload: - self._frame.append(i) - - self._frame.append(self.calculate_checksum()) - - # def calculate_checksum(self) -> int: - # return ( - # self.message_type - # + self.length - # + self.address - # + self.port - # + self.payload_type - # + self.payload - # ) & 255 - - @property - def message_type(self) -> int: - return self._frame[0] - - @property - def length(self) -> int: - return self._frame[1] - - @property - def address(self) -> int: - return self._frame[2] - - @property - def port(self) -> int: - return self._frame[3] - - @property - def payload_type(self) -> int: - return self._frame[4] - - @property - def checksum(self) -> int: - return self._frame[-1] - - -class WriteU8HarpMessage(WriteHarpMessage): - def __init__(self, address: int, value: int): - super().__init__(PayloadType.U8, value.to_bytes(1, byteorder="little"), address) - - @property - def payload(self) -> int: - return self.frame[5] - - -class WriteS8HarpMessage(WriteHarpMessage): - def __init__(self, address: int, value: int): - super().__init__( - PayloadType.S8, value.to_bytes(1, byteorder="little", signed=True), address - ) - - @property - def payload(self) -> int: - return int.from_bytes([self.frame[5]], byteorder="little", signed=True) - - -class WriteU16HarpMessage(WriteHarpMessage): - def __init__(self, address: int, value: int): - super().__init__( - PayloadType.U16, value.to_bytes(2, byteorder="little", signed=False), address, offset=1 - ) - - @property - def payload(self) -> int: - return int.from_bytes(self._frame[5:7], byteorder="little", signed=False) - - -class WriteS16HarpMessage(WriteHarpMessage): - def __init__(self, address: int, value: int): - super().__init__( - PayloadType.S16, - value.to_bytes(2, byteorder="little", signed=True), - address, - offset=1, - ) - - @property - def payload(self) -> int: - return int.from_bytes(self._frame[5:7], byteorder="little", signed=True) diff --git a/pyproject.toml b/pyproject.toml index 62037dc..c7d9169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,145 @@ -[tool.poetry] -name = "pyharp" -version = "0.1.0" +[project] +name = "harp" +dynamic = ["version"] description = "Library for data acquisition and control of devices implementing the Harp protocol." -authors = ["filcarv "] +authors = [{ name = "harp-tech", email = "contact@harp-tech.org" }] license = "MIT" +license-files = ["LICENSE"] readme = 'README.md' +keywords = [ + "harp", + "harp-protocol", + "data-acquisition", + "serial", + "hardware", + "device-control", +] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: System :: Hardware :: Hardware Drivers", +] +dependencies = [ + "harp-protocol", + "harp-device", + "harp-serial", + "harp-data", +] -[tool.poetry.dependencies] -python = "^3.4" -pyserial = "^3.4" - -[tool.poetry.dev-dependencies] -pytest = "^5.2" -mypy = "^0.782" -black = "^19.10b0" +[project.urls] +Homepage = "https://harp-tech.org/" +Repository = "https://github.com/harp-tech/pyharp/" +Documentation = "https://harp-tech.org/pyharp/" +"Bug Tracker" = "https://github.com/harp-tech/pyharp/issues" +Changelog = "https://github.com/harp-tech/pyharp/releases" [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["setuptools>=77", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] + +[tool.setuptools.packages.find] +where = ["src"] +include = ["harp*"] + +[tool.uv.sources] +harp-protocol = { workspace = true } +harp-device = { workspace = true } +harp-serial = { workspace = true } +harp-data = { workspace = true } +harp-benchmarks = { workspace = true } + +[tool.uv.workspace] +members = [ + "src/packages/*", +] + +[dependency-groups] +docs = [ + "griffe-fieldz>=0.2.1", + "mkdocs>=1.6.1", + "mkdocs-codeinclude-plugin>=0.2.1", + "mkdocs-git-authors-plugin>=0.9.4", + "mkdocs-include-markdown-plugin>=7.1.6", + "mkdocs-material>=9.6.9", + "mkdocstrings-python>=1.16.6", +] + +dev = [ + "codespell>=2.3.0", + "pyright>=1.1.411", + "pytest>=8.3.5", + "pytest-cov>=6.1.1", + "ruff>=0.11.0", + "harp-protocol", + "harp-device", + "harp-serial", + "harp-data", + "harp-benchmarks", + "typing-extensions>=4.15.0", +] + +[tool.codespell] +skip = "*.lock,./.venv,./site,./.git,./.mypy_cache,./.ruff_cache,./.pytest_cache,*.pyc" +check-filenames = true + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.pytest.ini_options] +pythonpath = ["."] +addopts = ["--import-mode=importlib", ] +python_files = [ + "tests.py", + "test_*.py" +] + + +[tool.ruff] +line-length = 100 +target-version = "py311" -[tool.poetry.scripts] -pyharp = "pyharp.main:main" +[tool.pyright] +pythonVersion = "3.11" +typeCheckingMode = "standard" +include = [ + "src/packages/harp-protocol/src", + "src/packages/harp-device/src", + "src/packages/harp-serial/src", + "src/packages/harp-data/src", +] +exclude = [ + "**/node_modules", + "**/__pycache__", + "**/.*", + ".venv", + "tests", +] +venvPath = "." +venv = ".venv" +reportImportCycles = "error" +reportUnusedImport = "error" +reportUnusedClass = "error" +reportUnusedFunction = "error" +reportUnusedVariable = "error" +reportDuplicateImport = "error" +reportWildcardImportFromLibrary = "error" +reportCallInDefaultInitializer = "error" +reportUnnecessaryIsInstance = "error" +reportUnnecessaryCast = "error" +reportUnnecessaryContains = "error" +reportAssertAlwaysTrue = "error" +reportSelfClsParameterName = "error" +reportUnusedExpression = "error" +reportMatchNotExhaustive = "error" diff --git a/src/harp/py.typed b/src/harp/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/packages/harp-benchmarks/README.md b/src/packages/harp-benchmarks/README.md new file mode 100644 index 0000000..35a77c3 --- /dev/null +++ b/src/packages/harp-benchmarks/README.md @@ -0,0 +1,57 @@ +# harp-benchmarks + +**Internal** parsing-speed benchmarks for the Harp register/payload API, run over every +register defined in [`register_models.py`](src/harp/benchmarks/register_models.py) (the +device.yml coverage model — also imported by the acceptance tests under `tests/`). + +> This package is **not published to PyPI** (`classifiers = ["Private :: Do Not Upload"]` +> in its `pyproject.toml`). It is a workspace member consumed only in dev/internal builds +> via the repo-root `pyproject.toml` `dev` dependency-group. Its console scripts ship with +> this package, so they never leak into the published `harp` distribution. + +## Layout + +| Path | Purpose | +| --- | --- | +| `src/harp/benchmarks/register_models.py` | Reference models for every device.yml register (fixtures shared with the acceptance tests). | +| `src/harp/benchmarks/_registers.py` | Registry: each register + a representative sample value; artifact paths. | +| `src/harp/benchmarks/generate.py` | Writes `./benchmark/data/_.bin`; exposes `ensure_corpus` (cache-aware). | +| `src/harp/benchmarks/benchmark.py` | Ensures corpora exist, then times `parse_bulk`, `parse_to_dataframe`, `to_columns`; writes `./benchmark/report.md`. | + +All generated artifacts (corpora + report) are written under **`./benchmark`** in the +current working directory — git-ignored and fully regenerable. + +## Usage + +Console scripts are declared in this package's `pyproject.toml`: + +```bash +# One command: generate-if-needed (cache honored), then benchmark + write the report. +uv run harp-benchmark +uv run harp-benchmark --runs 20 +uv run harp-benchmark --entries 100000 --force # rebuild smaller corpora +uv run harp-benchmark --only Version ComplexConfiguration + +# Generate corpora explicitly (optional — harp-benchmark does this for you). +uv run harp-benchmark-generate +uv run harp-benchmark-generate --entries 100000 +``` + +Equivalent module invocations: `uv run python -m harp.benchmarks.benchmark` / +`uv run python -m harp.benchmarks.generate`. + +## What is measured + +- **`parse_bulk`** — the core zero-copy strided-view parse into a `Batch` payload. + This is **lazy**: it builds strided views only and runs **no** converters. +- **`parse_to_dataframe`** — the full path to a pandas `DataFrame` (`copy=False`). +- **`to_columns`** (decode only) — `parse_bulk` views built once up front, then only + `payload.to_columns()` timed. This is where each field's `converter.decode_batch` + actually runs, with no file read and no pandas construction. + +`parse_bulk` and `parse_to_dataframe` are each timed in two modes: + +- **pre-read** — file read once up front; only deserialization is timed (isolates library speed). +- **re-read** — file re-read from disk on every run (real-world "load a dump" path, includes disk). + +The report also decomposes `parse_to_dataframe ≈ parse_bulk + to_columns + pandas overhead`. diff --git a/src/packages/harp-benchmarks/pyproject.toml b/src/packages/harp-benchmarks/pyproject.toml new file mode 100644 index 0000000..7ff7391 --- /dev/null +++ b/src/packages/harp-benchmarks/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "harp-benchmarks" +dynamic = ["version"] +description = "Internal parsing-speed benchmarks for the Harp register/payload API." +requires-python = ">=3.11" +# INTERNAL ONLY — never published to PyPI. The "Private :: Do Not Upload" trove +# classifier is rejected by PyPI (and twine/uv publish), so an accidental upload +# of this distribution fails fast. +classifiers = ["Private :: Do Not Upload"] +dependencies = [ + "harp-protocol", + "harp-data", + "numpy>=1.24", + "pandas>=2.0", +] + +[project.scripts] +harp-benchmark = "harp.benchmarks.benchmark:main" +harp-benchmark-generate = "harp.benchmarks.generate:main" + +[build-system] +requires = ["setuptools>=77", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +root = "../../.." + +[tool.setuptools.packages.find] +where = ["src"] +include = ["harp*"] diff --git a/src/packages/harp-benchmarks/src/harp/benchmarks/__init__.py b/src/packages/harp-benchmarks/src/harp/benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/packages/harp-benchmarks/src/harp/benchmarks/_registers.py b/src/packages/harp-benchmarks/src/harp/benchmarks/_registers.py new file mode 100644 index 0000000..7033400 --- /dev/null +++ b/src/packages/harp-benchmarks/src/harp/benchmarks/_registers.py @@ -0,0 +1,161 @@ +"""Shared benchmark fixtures: every register from ``harp.benchmarks.register_models`` +paired with a representative sample value. + +Both ``generate.py`` (writes the .bin corpora) and ``benchmark.py`` (times parsing) +import :data:`BENCHMARK_REGISTERS` from here so the two stay in lock-step: the value +used to *format* each frame is the same one whose parsed shape we benchmark. + +The sample values mirror ``register_models.main()``'s round-trip fixtures — they +exercise the full spread of payload shapes the Harp protocol allows (trivial +scalars, struct payloads with byte gaps, masked sub-fields, custom converters, +enum/flag single-member unwrap). + +All generated artifacts live under ``./benchmark`` in the current working directory. +""" + +from pathlib import Path +from typing import Any, NamedTuple + +import numpy as np + +from harp.benchmarks.register_models import ( + AnalogData, + AnalogDataPayload, + BitmaskSplitter, + BitmaskSplitterPayload, + ComplexConfiguration, + ComplexConfigurationPayload, + Counter0, + CustomMemberConverter, + CustomMemberConverterPayload, + CustomPayload, + CustomRawPayload, + DigitalInputs, + EncoderMode, + EncoderModeMask, + PortDigitalIOS, + PortDIOSet, + PulseDO0, + PulseDOPort0, + PwmPort, + StartPulse, + StartPulsePayload, + StartPulseTrain, + StartPulseTrainPayload, + Version, + VersionPayload, +) +from harp.protocol import HarpVersion, RegisterBase + +ARTIFACTS_DIR = Path("benchmark").resolve() +DATA_DIR = ARTIFACTS_DIR / "data" +REPORT_PATH = ARTIFACTS_DIR / "report.md" + + +class BenchmarkedRegister(NamedTuple): + """A register under benchmark together with a value that ``format()`` accepts.""" + + name: str + register: type[RegisterBase[Any]] + value: Any + timestamped: bool = True + + @property + def address(self) -> int: + return self.register.address + + @property + def filename(self) -> str: + return f"{self.name}_{self.address}.bin" + + +def _base_registers() -> list[BenchmarkedRegister]: + """One (timestamped) fixture per register — :func:`_build` derives the untimestamped twin.""" + return [ + BenchmarkedRegister("DigitalInputs", DigitalInputs, np.uint8(0b1010)), + BenchmarkedRegister( + "AnalogData", + AnalogData, + AnalogDataPayload( + Analog0=np.float32(1.0), + Analog1=np.float32(2.0), + Analog2=np.float32(3.0), + Accelerometer=np.array([4, 5, 6], dtype=np.float32), + ), + ), + BenchmarkedRegister( + "ComplexConfiguration", + ComplexConfiguration, + ComplexConfigurationPayload( + PwmPort=PwmPort.Pwm2, + DutyCycle=np.float32(0.5), + Frequency=np.float32(1000.0), + EventsEnabled=True, + Delta=np.uint32(42), + ), + ), + BenchmarkedRegister( + "Version", + Version, + VersionPayload( + ProtocolVersion=HarpVersion(2, 0, 0), + FirmwareVersion=HarpVersion(1, 2, 3), + HardwareVersion=HarpVersion(1, 0, 0), + CoreId="abc", + InterfaceHash=np.arange(20, dtype=np.uint8), + ), + ), + BenchmarkedRegister("CustomPayload", CustomPayload, HarpVersion(3, 1, 4)), + BenchmarkedRegister("CustomRawPayload", CustomRawPayload, HarpVersion(0, 0, 1)), + BenchmarkedRegister( + "CustomMemberConverter", + CustomMemberConverter, + CustomMemberConverterPayload(Header=np.uint8(7), Data=-1234), + ), + BenchmarkedRegister( + "BitmaskSplitter", + BitmaskSplitter, + BitmaskSplitterPayload(Low=np.int32(0xA), High=np.int32(0x5)), + ), + BenchmarkedRegister("Counter0", Counter0, np.int32(-100000)), + BenchmarkedRegister( + "PortDIOSet", + PortDIOSet, + PortDigitalIOS.DIO0 | PortDigitalIOS.DIO3, + ), + BenchmarkedRegister("PulseDOPort0", PulseDOPort0, np.uint16(5)), + BenchmarkedRegister("PulseDO0", PulseDO0, np.uint16(9)), + BenchmarkedRegister( + "StartPulse", + StartPulse, + StartPulsePayload(DigitalOutput=PwmPort.Pwm1, PulseWidth=np.uint16(300)), + ), + BenchmarkedRegister( + "StartPulseTrain", + StartPulseTrain, + StartPulseTrainPayload( + DigitalOutput=PwmPort.Pwm1, + PulseWidth=np.uint16(300), + Frequency=np.uint8(200), + PulseCount=np.uint8(50), + ), + ), + BenchmarkedRegister("EncoderMode", EncoderMode, EncoderModeMask.Displacement), + ] + + +def _build() -> list[BenchmarkedRegister]: + """Expand every base fixture into a timestamped + untimestamped pair. + + ``parse_bulk`` detects timestamping from the wire format at runtime (not + statically), so every register needs a corpus of each shape to exercise both + the eager (``timestamps`` present) and ``None`` decode paths. + """ + registers: list[BenchmarkedRegister] = [] + for reg in _base_registers(): + registers.append(reg) + registers.append(reg._replace(name=f"{reg.name}NoTimestamp", timestamped=False)) + return registers + + +BENCHMARK_REGISTERS: list[BenchmarkedRegister] = _build() diff --git a/src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py b/src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py new file mode 100644 index 0000000..2d75563 --- /dev/null +++ b/src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py @@ -0,0 +1,347 @@ +import argparse +import platform +import sys +from dataclasses import dataclass +from pathlib import Path +from statistics import mean, stdev +from time import perf_counter +from typing import Callable + +import numpy as np +import pandas as pd +from harp.benchmarks._registers import ( + BENCHMARK_REGISTERS, + DATA_DIR, + REPORT_PATH, + BenchmarkedRegister, +) +from harp.benchmarks.generate import ensure_corpus +from harp.data import parse_to_dataframe + +_MIB = 1024**2 + + +@dataclass +class TimingStats: + """Per-run timings (seconds) and throughput derived from the mean.""" + + min: float + mean: float + max: float + stdev: float + frames: int + file_bytes: int + + @property + def mframes_per_s(self) -> float: + return (self.frames / self.mean) / 1e6 + + @property + def mib_per_s(self) -> float: + return (self.file_bytes / self.mean) / _MIB + + +def _time(fn: Callable[[], object], *, runs: int, frames: int, file_bytes: int) -> TimingStats: + fn() # warm-up (imports, caches, first-touch pages) — not measured + samples: list[float] = [] + for _ in range(runs): + t0 = perf_counter() + fn() + samples.append(perf_counter() - t0) + return TimingStats( + min=min(samples), + mean=mean(samples), + max=max(samples), + stdev=stdev(samples) if len(samples) > 1 else 0.0, + frames=frames, + file_bytes=file_bytes, + ) + + +@dataclass +class RegisterResult: + name: str + address: int + frames: int + stride: int + payload_bytes: int + file_bytes: int + bulk_preread: TimingStats + bulk_reread: TimingStats + cols: TimingStats + df_preread: TimingStats + df_reread: TimingStats + + +def _dataset_info(raw: bytes, payload_bytes: int) -> tuple[int, int]: + data = np.frombuffer(raw, dtype=np.uint8) + stride = int(data[1]) + 2 + nrows = len(data) // stride + return nrows, stride + + +def benchmark_register(reg: BenchmarkedRegister, path: Path, *, runs: int) -> RegisterResult: + register = reg.register + raw = path.read_bytes() + file_bytes = len(raw) + payload_bytes = register.payload_class.dtype.itemsize + frames, stride = _dataset_info(raw, payload_bytes) + + bulk_pre = _time( + lambda: register.parse_bulk(raw, parse_timestamp=True), + runs=runs, + frames=frames, + file_bytes=file_bytes, + ) + bulk_re = _time( + lambda: register.parse_bulk(path.read_bytes(), parse_timestamp=True), + runs=runs, + frames=frames, + file_bytes=file_bytes, + ) + # Decode only: pre-parse the bulk views once, then time to_columns() alone — + # this is where every converter's decode_batch runs, with no file read and no + # pandas DataFrame construction. Matches parse_to_dataframe's decode options. + _, _, _, payload = register.parse_bulk(raw, parse_timestamp=True) + cols = _time( + lambda: payload.to_columns(decode_enums=True, demux_bit_masks=False), + runs=runs, + frames=frames, + file_bytes=file_bytes, + ) + df_pre = _time( + lambda: parse_to_dataframe(register, raw, timestamp=reg.timestamped), + runs=runs, + frames=frames, + file_bytes=file_bytes, + ) + df_re = _time( + lambda: parse_to_dataframe(register, path.read_bytes(), timestamp=reg.timestamped), + runs=runs, + frames=frames, + file_bytes=file_bytes, + ) + + return RegisterResult( + name=reg.name, + address=reg.address, + frames=frames, + stride=stride, + payload_bytes=payload_bytes, + file_bytes=file_bytes, + bulk_preread=bulk_pre, + bulk_reread=bulk_re, + cols=cols, + df_preread=df_pre, + df_reread=df_re, + ) + + +def _fmt_ms(s: float) -> str: + return f"{s * 1e3:.2f}" + + +def build_report(results: list[RegisterResult], *, runs: int) -> str: + lines: list[str] = [] + lines.append("# Harp parsing benchmark\n") + lines.append( + "Parsing throughput for every register in `harp.benchmarks.register_models`, " + "measured over Harp wire-format dumps generated by `harp.benchmarks.generate`.\n" + ) + + # Environment + lines.append("## Environment\n") + lines.append("| Key | Value |") + lines.append("| --- | --- |") + lines.append(f"| Platform | {platform.platform()} |") + lines.append(f"| Python | {sys.version.split()[0]} |") + lines.append(f"| NumPy | {np.__version__} |") + lines.append(f"| pandas | {pd.__version__} |") + lines.append(f"| Runs per measurement | {runs} |") + lines.append( + "| Measured op | one full-file parse; min/mean/stdev over runs (1 warm-up discarded) |\n" + ) + + # Dataset summary + lines.append("## Datasets\n") + lines.append("| Register | Addr | Frames | Payload (B) | Frame/stride (B) | File (MiB) |") + lines.append("| --- | ---: | ---: | ---: | ---: | ---: |") + for r in results: + lines.append( + f"| {r.name} | {r.address} | {r.frames:,} | {r.payload_bytes} | " + f"{r.stride} | {r.file_bytes / _MIB:.1f} |" + ) + lines.append("") + + def _table( + title: str, + note: str, + pick: Callable[[RegisterResult], tuple[TimingStats, TimingStats]], + ) -> None: + lines.append(f"## {title}\n") + lines.append(f"{note}\n") + lines.append( + "| Register | Frames | pre mean (ms) | pre min (ms) | pre Mframes/s | pre MiB/s " + "| re mean (ms) | re Mframes/s | re MiB/s |" + ) + lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") + for r in results: + pre, re = pick(r) + lines.append( + f"| {r.name} | {r.frames:,} | {_fmt_ms(pre.mean)} | {_fmt_ms(pre.min)} | " + f"{pre.mframes_per_s:.2f} | {pre.mib_per_s:,.0f} | " + f"{_fmt_ms(re.mean)} | {re.mframes_per_s:.2f} | {re.mib_per_s:,.0f} |" + ) + lines.append("") + + _table( + "`parse_bulk` (core zero-copy parse)", + "`pre` = parse a pre-read buffer; `re` = re-read the file from disk each run. " + "Note `parse_bulk` only builds lazy strided views — it does **not** run " + "converters — so timings are near-uniform regardless of payload shape.", + lambda r: (r.bulk_preread, r.bulk_reread), + ) + _table( + "`parse_to_dataframe` (full path to pandas, copy=False)", + "`pre` = parse a pre-read buffer; `re` = read file + parse each run.", + lambda r: (r.df_preread, r.df_reread), + ) + + # Decode-only table (single mode): to_columns() runs every field's + # converter.decode_batch, with no file read and no pandas construction. + lines.append("## `to_columns` (decode only — where converters run)\n") + lines.append( + "Isolates the decode step: `parse_bulk` views are built once up front, then " + "only `payload.to_columns()` is timed. This is where each field's " + "`converter.decode_batch` executes. Registers whose converters loop in Python " + "(`HarpVersionConverter`, `StringConverter`, `BytesToIntConverter` → object " + "dtype) dominate here; vectorized converters stay cheap.\n" + ) + lines.append("| Register | Frames | mean (ms) | min (ms) | stdev (ms) | Mframes/s | MiB/s |") + lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |") + for r in results: + c = r.cols + lines.append( + f"| {r.name} | {r.frames:,} | {_fmt_ms(c.mean)} | {_fmt_ms(c.min)} | " + f"{_fmt_ms(c.stdev)} | {c.mframes_per_s:.2f} | {c.mib_per_s:,.0f} |" + ) + lines.append("") + + # Decomposition: parse_to_dataframe(pre) ≈ parse_bulk(pre) + to_columns + pandas. + lines.append("## Decomposition (pre-read means, ms)\n") + lines.append( + "`parse_to_dataframe` ≈ `parse_bulk` (build views) + `to_columns` (decode) + " + "pandas DataFrame construction. The residual column is `df − bulk − to_columns`, " + "i.e. the pandas/column-assembly overhead. Note the three terms are timed in " + "separate loops, so for converter-dominated registers (large mean, large stdev) " + "the residual is within noise and can even go slightly negative.\n" + ) + lines.append("| Register | parse_bulk | to_columns | parse_to_dataframe | pandas residual |") + lines.append("| --- | ---: | ---: | ---: | ---: |") + for r in results: + residual = r.df_preread.mean - r.bulk_preread.mean - r.cols.mean + lines.append( + f"| {r.name} | {_fmt_ms(r.bulk_preread.mean)} | {_fmt_ms(r.cols.mean)} | " + f"{_fmt_ms(r.df_preread.mean)} | {_fmt_ms(residual)} |" + ) + lines.append("") + + return "\n".join(lines) + + +def _select(only): + selected = BENCHMARK_REGISTERS + if only: + wanted = set(only) + selected = [r for r in BENCHMARK_REGISTERS if r.name in wanted] + missing = wanted - {r.name for r in selected} + if missing: + raise SystemExit(f"Unknown register name(s): {', '.join(sorted(missing))}") + return selected + + +def _prepare_corpora(selected, *, entries: int, force: bool, data_dir: Path) -> None: + """Generate any missing corpora (cache honored unless ``force``), printing progress.""" + data_dir.mkdir(parents=True, exist_ok=True) + n = len(selected) + mode = "rebuilding" if force else "cache honored" + print(f"Preparing {n} corpus file(s) in {data_dir} ({mode}, {entries:,} frames each):") + for i, reg in enumerate(selected, 1): + print(f" [{i}/{n}] {reg.name:<24s} ", end="", flush=True) + _, generated = ensure_corpus(reg, entries, force=force, data_dir=data_dir) + print("generated" if generated else "cached") + print() + + +def _use_utf8_console() -> None: + """Best-effort: make console output UTF-8 (the docstrings/report use ≈, →, −).""" + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except (AttributeError, ValueError): + pass + + +def main() -> None: + _use_utf8_console() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--runs", type=int, default=10, help="repeats per measurement (default: 10)" + ) + parser.add_argument( + "--entries", + type=int, + default=1_000_000, + help="frames per corpus when generating (default: 1,000,000)", + ) + parser.add_argument( + "--force", action="store_true", help="regenerate corpora even if cached files exist" + ) + parser.add_argument( + "--only", nargs="+", metavar="NAME", help="restrict to these register names (default: all)" + ) + parser.add_argument( + "--dir", + type=Path, + default=DATA_DIR, + help=f"directory containing/receiving corpus files (default: {DATA_DIR})", + ) + parser.add_argument("--report", type=Path, default=REPORT_PATH) + parser.add_argument( + "--head", + action="store_true", + help="print head(5) of each register's DataFrame to stdout (not saved to the report)", + ) + args = parser.parse_args() + + selected = _select(args.only) + + # Ensure the data exists before benchmarking (honor the cache unless --force). + _prepare_corpora(selected, entries=args.entries, force=args.force, data_dir=args.dir) + + n = len(selected) + results: list[RegisterResult] = [] + print(f"Benchmarking {n} register(s), {args.runs} runs each:") + for i, reg in enumerate(selected, 1): + path = args.dir / reg.filename + print(f" [{i}/{n}] {reg.name:<24s} ", end="", flush=True) + res = benchmark_register(reg, path, runs=args.runs) + results.append(res) + print( + f"bulk={_fmt_ms(res.bulk_preread.mean):>8s}ms " + f"to_columns={_fmt_ms(res.cols.mean):>9s}ms " + f"df={_fmt_ms(res.df_preread.mean):>9s}ms" + ) + if args.head: + df = parse_to_dataframe(reg.register, path.read_bytes(), timestamp=True) + print(df.head(5)) + print() + + args.report.parent.mkdir(parents=True, exist_ok=True) + report = build_report(results, runs=args.runs) + args.report.write_text(report, encoding="utf-8") + print(f"\nReport written to {args.report}") + + +if __name__ == "__main__": + main() diff --git a/src/packages/harp-benchmarks/src/harp/benchmarks/generate.py b/src/packages/harp-benchmarks/src/harp/benchmarks/generate.py new file mode 100644 index 0000000..64c62ad --- /dev/null +++ b/src/packages/harp-benchmarks/src/harp/benchmarks/generate.py @@ -0,0 +1,105 @@ +import argparse +import sys +from pathlib import Path + +from harp.benchmarks._registers import BENCHMARK_REGISTERS, DATA_DIR, BenchmarkedRegister + +_TIMESTAMP = 42 + + +def corpus_path(reg: BenchmarkedRegister, data_dir: Path = DATA_DIR): + """Path to ``reg``'s corpus file under ``data_dir``.""" + return data_dir / reg.filename + + +def _frame_timestamp(reg: BenchmarkedRegister) -> int | None: + return _TIMESTAMP if reg.timestamped else None + + +def generate_one( + reg: BenchmarkedRegister, entries: int, data_dir: Path = DATA_DIR +) -> tuple[str, int, int]: + """Write ``entries`` frames for ``reg``. Returns (path, frame_size, file_size).""" + frame = reg.register.format(reg.value, timestamp=_frame_timestamp(reg)) + path = corpus_path(reg, data_dir) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(frame * entries) + return str(path), len(frame), path.stat().st_size + + +def ensure_corpus( + reg: BenchmarkedRegister, entries: int, *, force: bool = False, data_dir: Path = DATA_DIR +) -> tuple[object, bool]: + """Generate ``reg``'s corpus unless a matching cached file already exists. + + The cache is honored only when the existing file's size matches ``entries`` + exactly (frame_size * entries); a stale file (different entry count) is rebuilt. + Returns (path, generated). + """ + path = corpus_path(reg, data_dir) + if path.exists() and not force: + frame_size = len(reg.register.format(reg.value, timestamp=_frame_timestamp(reg))) + if path.stat().st_size == frame_size * entries: + return path, False + generate_one(reg, entries, data_dir) + return path, True + + +def _use_utf8_console() -> None: + """Best-effort: make console output UTF-8 (docstrings use non-ASCII glyphs).""" + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except (AttributeError, ValueError): + pass + + +def _select(only): + selected = BENCHMARK_REGISTERS + if only: + wanted = set(only) + selected = [r for r in BENCHMARK_REGISTERS if r.name in wanted] + missing = wanted - {r.name for r in selected} + if missing: + raise SystemExit(f"Unknown register name(s): {', '.join(sorted(missing))}") + return selected + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--entries", type=int, default=1_000_000, help="frames per register (default: 1,000,000)" + ) + parser.add_argument( + "--only", + nargs="+", + metavar="NAME", + help="restrict generation to these register names (default: all)", + ) + parser.add_argument( + "--dir", + type=Path, + default=DATA_DIR, + help=f"directory to write corpus files into (default: {DATA_DIR})", + ) + _use_utf8_console() + args = parser.parse_args() + + selected = _select(args.only) + args.dir.mkdir(parents=True, exist_ok=True) + + print(f"Generating {args.entries:,} frames/register into {args.dir}\n") + total_bytes = 0 + for reg in selected: + _, frame_size, file_size = generate_one(reg, args.entries, args.dir) + total_bytes += file_size + print( + f" {reg.name:<24s} addr={reg.address:<4d} " + f"frame={frame_size:>3d}B -> {reg.filename:<32s} " + f"({file_size / 1024**2:8.1f} MiB)" + ) + print(f"\nDone. {len(selected)} file(s), {total_bytes / 1024**2:,.1f} MiB total.") + + +if __name__ == "__main__": + main() diff --git a/src/packages/harp-benchmarks/src/harp/benchmarks/py.typed b/src/packages/harp-benchmarks/src/harp/benchmarks/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/packages/harp-benchmarks/src/harp/benchmarks/register_models.py b/src/packages/harp-benchmarks/src/harp/benchmarks/register_models.py new file mode 100644 index 0000000..d6aac12 --- /dev/null +++ b/src/packages/harp-benchmarks/src/harp/benchmarks/register_models.py @@ -0,0 +1,427 @@ +import enum +from typing import Any, ClassVar + +import numpy as np +from numpy.typing import NDArray + +from harp.protocol import ( + AnonymousPayload, + BitMask, + BoolConverter, + Converter, + Field, + GroupMask, + HarpMessage, + IdentityConverter, + HarpVersionConverter, + HarpVersion, + PayloadType, + RegisterBase, + RegisterS32, + RegisterU8, + RegisterU16, + StringConverter, + StructPayload, +) + +# This file targets the device.yml here +# https://raw.githubusercontent.com/harp-tech/generators/refs/heads/main/tests/Metadata/device.yml + + +# =========================================================================== +# device.yml bitMasks + groupMasks +# =========================================================================== + + +class PortDigitalIOS(enum.IntFlag): + """device.yml bitMasks.PortDigitalIOS (bits up to 0x800 — see PortDIOSet).""" + + DIO0 = 0x1 + DIO1 = 0x2 + DIO2 = 0x4 + DIO3 = 0x8 + DIPort0 = 0x100 + TestDIPort1 = 0x200 + SupplyPort0 = 0x400 + PortDIO1 = 0x800 + + +class PwmPort(enum.IntEnum): + """device.yml groupMasks.PwmPort (note Pwm3 = 0xA).""" + + Pwm0 = 0x1 + Pwm1 = 0x2 + Pwm2 = 0x4 + Pwm3 = 0xA + + +class EncoderModeMask(enum.IntEnum): + """device.yml groupMasks.EncoderModeMask.""" + + Position = 0x0 + Displacement = 0x1 + + +# =========================================================================== +# Custom interfaceType converters — byte-based, register-element-agnostic. +# =========================================================================== + + +class BytesToIntConverter(Converter[int]): + """N raw bytes (little-endian) <-> Python int. Models ``interfaceType: int`` over a sub-array.""" + + def __init__(self, length: int, *, signed: bool = False) -> None: + self._length = length + self._signed = signed + endian = "<" if length > 1 else "" + kind = "i" if signed else "u" + self._native = IdentityConverter(f"{endian}{kind}{length}") + self.dtype = np.dtype((np.uint8, (length,))) + + def decode_scalar(self, view: np.generic) -> int: + return int.from_bytes(bytes(np.asarray(view).tolist()), "little", signed=self._signed) + + def decode_batch(self, view: NDArray[np.generic]) -> Any: + rows = np.atleast_2d(view).reshape(-1, self._length) + return self._native.decode_batch(rows.view(self._native.dtype).reshape(-1)) + + def encode_into(self, view: NDArray[np.generic], value: int) -> None: + view[...] = np.frombuffer( + int(value).to_bytes(self._length, "little", signed=self._signed), dtype=np.uint8 + ) + + +# =========================================================================== +# 32 DigitalInputs : U8, Event -> trivial scalar register +# =========================================================================== + + +class DigitalInputs(RegisterU8): + address: ClassVar[int] = 32 + + +# =========================================================================== +# 33 AnalogData : Float[6], Event — named sub-views + a 3-float sub-array. +# =========================================================================== + + +class AnalogDataPayload(StructPayload[np.float32], length=6): + Analog0: np.float32 = Field(IdentityConverter(np.float32), offset=0) + Analog1: np.float32 = Field(IdentityConverter(np.float32), offset=1) + Analog2: np.float32 = Field(IdentityConverter(np.float32), offset=2) + Accelerometer: NDArray[np.float32] = Field( + IdentityConverter(np.dtype((np.float32, (3,)))), offset=3 + ) + + +class AnalogData(RegisterBase[AnalogDataPayload]): + address: ClassVar[int] = 33 + payload_type: ClassVar[PayloadType] = PayloadType.Float + payload_class = AnalogDataPayload + + +# =========================================================================== +# 34 ComplexConfiguration : U8[17], Write — byte gap at bytes 1..3. +# =========================================================================== + + +class ComplexConfigurationPayload(StructPayload[np.uint8], length=17): + PwmPort: "PwmPort" = GroupMask( + enum=PwmPort, mask=0xFF, offset=0 + ) # quoted: member name shadows enum type + DutyCycle: np.float32 = Field(IdentityConverter(np.float32), offset=4) + Frequency: np.float32 = Field(IdentityConverter(np.float32), offset=8) + EventsEnabled: bool = Field(BoolConverter(), offset=12) + Delta: np.uint32 = Field(IdentityConverter(np.uint32), offset=13) + + +class ComplexConfiguration(RegisterBase[ComplexConfigurationPayload]): + address: ClassVar[int] = 34 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = ComplexConfigurationPayload + + +# =========================================================================== +# 35 Version : U8[32], Event — HarpVersion x3 (3-byte) + string + raw hash. +# =========================================================================== + + +class VersionPayload(StructPayload[np.uint8], length=32): + ProtocolVersion: HarpVersion = Field(HarpVersionConverter(np.uint8), offset=0) + FirmwareVersion: HarpVersion = Field(HarpVersionConverter(np.uint8), offset=3) + HardwareVersion: HarpVersion = Field(HarpVersionConverter(np.uint8), offset=6) + CoreId: str = Field(StringConverter(3), offset=9) + InterfaceHash: NDArray[np.uint8] = Field( + IdentityConverter(np.dtype((np.uint8, (20,)))), offset=12 + ) + + +class Version(RegisterBase[VersionPayload]): + address: ClassVar[int] = 35 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = VersionPayload + + +# =========================================================================== +# 36 / 37 CustomPayload / CustomRawPayload : U32[3] — register-level +# interfaceType HarpVersion. Single full-span member -> parse() unwraps. +# =========================================================================== + + +class CustomPayloadPayload(AnonymousPayload[np.uint32]): + __value__: HarpVersion = Field(HarpVersionConverter(np.uint32)) + + +class CustomPayload(RegisterBase[HarpVersion]): + address: ClassVar[int] = 36 + payload_type: ClassVar[PayloadType] = PayloadType.U32 + payload_class = CustomPayloadPayload + + +class CustomRawPayloadPayload(AnonymousPayload[np.uint32]): + __value__: HarpVersion = Field(HarpVersionConverter(np.uint32)) + + +class CustomRawPayload(RegisterBase[HarpVersion]): + address: ClassVar[int] = 37 + payload_type: ClassVar[PayloadType] = PayloadType.U32 + payload_class = CustomRawPayloadPayload + + +# =========================================================================== +# 38 CustomMemberConverter : U8[3], Read — Header (uint8) + Data (2 bytes -> int). +# =========================================================================== + + +class CustomMemberConverterPayload(StructPayload[np.uint8], length=3): + Header: np.uint8 = Field(IdentityConverter(np.uint8)) + Data: int = Field(BytesToIntConverter(2, signed=True), offset=1) + + +class CustomMemberConverter(RegisterBase[CustomMemberConverterPayload]): + address: ClassVar[int] = 38 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = CustomMemberConverterPayload + + +# =========================================================================== +# 39 BitmaskSplitter : U8, Write — Low (mask 0xF, int) + High (mask 0xF0, int). +# =========================================================================== + + +class BitmaskSplitterPayload(StructPayload[np.uint8]): + Low: np.int32 = Field(IdentityConverter(np.int32), mask=0x0F) + High: np.int32 = Field(IdentityConverter(np.int32), mask=0xF0) + + +class BitmaskSplitter(RegisterBase[BitmaskSplitterPayload]): + address: ClassVar[int] = 39 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = BitmaskSplitterPayload + + +# =========================================================================== +# 40 Counter0 : S32, Event -> trivial scalar register +# =========================================================================== + + +class Counter0(RegisterS32): + address: ClassVar[int] = 40 + + +# =========================================================================== +# 41 PortDIOSet : U8, Write — bitMask PortDigitalIOS. A single BitMask over the +# whole byte; bits >= 0x100 can't fit a U8 so they are dropped. Single-member +# -> parse() unwraps to a bare PortDigitalIOS. +# =========================================================================== + + +class PortDIOSetPayload(AnonymousPayload[np.uint8]): + __value__: PortDigitalIOS = BitMask(enum=PortDigitalIOS, mask=0xFF) + + +class PortDIOSet(RegisterBase[PortDigitalIOS]): + address: ClassVar[int] = 41 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = PortDIOSetPayload + + +# =========================================================================== +# 42 / 43 PulseDOPort0 / PulseDO0 : U16, Write +# =========================================================================== + + +class PulseDOPort0(RegisterU16): + address: ClassVar[int] = 42 + + +class PulseDO0(RegisterU16): + address: ClassVar[int] = 43 + + +# =========================================================================== +# 100 StartPulse : U16, Write — two overlapping views of one word. +# =========================================================================== + + +class StartPulsePayload(StructPayload[np.uint16]): + DigitalOutput: PwmPort = GroupMask(enum=PwmPort, mask=0xC00) + PulseWidth: np.uint16 = Field(IdentityConverter(np.uint16), mask=0x3FF) + + +class StartPulse(RegisterBase[StartPulsePayload]): + address: ClassVar[int] = 100 + payload_type: ClassVar[PayloadType] = PayloadType.U16 + payload_class = StartPulsePayload + + +# =========================================================================== +# 101 StartPulseTrain : U16[2], Write — 4 masked members across two words. +# =========================================================================== + + +class StartPulseTrainPayload(StructPayload[np.uint16], length=2): + DigitalOutput: PwmPort = GroupMask(enum=PwmPort, mask=0xC00, offset=0) + PulseWidth: np.uint16 = Field(IdentityConverter(np.uint16), mask=0x3FF, offset=0) + Frequency: np.uint8 = Field( + IdentityConverter(np.uint8), mask=0xFF00, offset=1, default=np.uint8(1) + ) + PulseCount: np.uint8 = Field(IdentityConverter(np.uint8), mask=0xFF, offset=1) + + +class StartPulseTrain(RegisterBase[StartPulseTrainPayload]): + address: ClassVar[int] = 101 + payload_type: ClassVar[PayloadType] = PayloadType.U16 + payload_class = StartPulseTrainPayload + + +# =========================================================================== +# 103 EncoderMode : U8, Write — whole-register groupMask. +# =========================================================================== + + +class EncoderModePayload(AnonymousPayload[np.uint8]): + __value__: EncoderModeMask = GroupMask(enum=EncoderModeMask, mask=0xFF) + + +class EncoderMode(RegisterBase[EncoderModeMask]): + address: ClassVar[int] = 103 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = EncoderModePayload + + +# =========================================================================== +# Round-trip smoke test +# =========================================================================== + + +def _roundtrip(register: type[RegisterBase[Any]], value: Any) -> Any: + frame = register.format(value) + return register.parse(HarpMessage.parse(frame)) + + +def main() -> None: # pragma: no cover - manual exploration entry point + print("=== Every device.yml register, format -> parse round-trip ===\n") + + assert _roundtrip(DigitalInputs, np.uint8(0b1010)) == 0b1010 + print("DigitalInputs OK") + + ad = AnalogDataPayload( + Analog0=np.float32(1.0), + Analog1=np.float32(2.0), + Analog2=np.float32(3.0), + Accelerometer=np.array([4, 5, 6], dtype=np.float32), + ) + p = _roundtrip(AnalogData, ad) + assert float(p.Analog0) == 1.0 and float(p.Analog2) == 3.0 + np.testing.assert_array_equal(p.Accelerometer, [4, 5, 6]) + print(f"AnalogData OK ({AnalogDataPayload.dtype.itemsize} bytes)") + + cc = ComplexConfigurationPayload( + PwmPort=PwmPort.Pwm2, + DutyCycle=np.float32(0.5), + Frequency=np.float32(1000.0), + EventsEnabled=True, + Delta=np.uint32(42), + ) + p = _roundtrip(ComplexConfiguration, cc) + assert p.PwmPort == PwmPort.Pwm2 and p.EventsEnabled is True and int(p.Delta) == 42 + assert float(p.DutyCycle) == 0.5 + assert ComplexConfigurationPayload.dtype.itemsize == 17 + assert cc.raw_payload.tobytes()[1:4] == b"\x00\x00\x00" + print( + f"ComplexConfiguration OK ({ComplexConfigurationPayload.dtype.itemsize} bytes, gap 1..3)" + ) + + ver = VersionPayload( + ProtocolVersion=HarpVersion(2, 0, 0), + FirmwareVersion=HarpVersion(1, 2, 3), + HardwareVersion=HarpVersion(1, 0, 0), + CoreId="abc", + InterfaceHash=np.arange(20, dtype=np.uint8), + ) + p = _roundtrip(Version, ver) + assert p.ProtocolVersion == HarpVersion(2, 0, 0) and p.CoreId == "abc" + np.testing.assert_array_equal(p.InterfaceHash, np.arange(20)) + print(f"Version OK ({VersionPayload.dtype.itemsize} bytes)") + + p = _roundtrip(CustomPayload, HarpVersion(3, 1, 4)) + assert p == HarpVersion(3, 1, 4) # single-member unwrap -> bare HarpVersion + p = _roundtrip(CustomRawPayload, HarpVersion(0, 0, 1)) + assert p == HarpVersion(0, 0, 1) + print("CustomPayload/RawPayload OK (single-member unwrap)") + + p = _roundtrip( + CustomMemberConverter, CustomMemberConverterPayload(Header=np.uint8(7), Data=-1234) + ) + assert int(p.Header) == 7 and int(p.Data) == -1234 + print("CustomMemberConverter OK") + + p = _roundtrip(BitmaskSplitter, BitmaskSplitterPayload(Low=0xA, High=0x5)) + assert int(p.Low) == 0xA and int(p.High) == 0x5 + assert p.raw_payload.tobytes() == bytes([0x5A]) + print("BitmaskSplitter OK") + + assert int(_roundtrip(Counter0, np.int32(-100000))) == -100000 + print("Counter0 OK") + + p = _roundtrip(PortDIOSet, PortDigitalIOS.DIO0 | PortDigitalIOS.DIO3) + assert p == PortDigitalIOS.DIO0 | PortDigitalIOS.DIO3 # single-member unwrap + assert PortDigitalIOS.DIO1 not in p + assert PortDIOSetPayload.dtype.itemsize == 1 + print("PortDIOSet OK") + + assert int(_roundtrip(PulseDOPort0, np.uint16(5))) == 5 + assert int(_roundtrip(PulseDO0, np.uint16(9))) == 9 + print("PulseDOPort0 / PulseDO0 OK") + + p = _roundtrip( + StartPulse, StartPulsePayload(DigitalOutput=PwmPort.Pwm1, PulseWidth=np.uint16(300)) + ) + assert p.DigitalOutput == PwmPort.Pwm1 and int(p.PulseWidth) == 300 + print("StartPulse OK") + + p = _roundtrip( + StartPulseTrain, + StartPulseTrainPayload( + DigitalOutput=PwmPort.Pwm1, + PulseWidth=np.uint16(300), + Frequency=np.uint8(200), + PulseCount=np.uint8(50), + ), + ) + assert p.DigitalOutput == PwmPort.Pwm1 and int(p.PulseWidth) == 300 + assert int(p.Frequency) == 200 and int(p.PulseCount) == 50 + assert StartPulseTrainPayload.dtype.itemsize == 4 + assert int(StartPulseTrainPayload(PulseCount=np.uint8(3)).Frequency) == 1 # defaultValue + print("StartPulseTrain OK (4 masked members, 2 words, default Frequency=1)") + + p = _roundtrip(EncoderMode, EncoderModeMask.Displacement) + assert p == EncoderModeMask.Displacement # single-member unwrap + print("EncoderMode OK") + + print("\nAll device.yml registers round-trip cleanly.") + + +if __name__ == "__main__": + main() diff --git a/src/packages/harp-data/README.md b/src/packages/harp-data/README.md new file mode 100644 index 0000000..abfdbde --- /dev/null +++ b/src/packages/harp-data/README.md @@ -0,0 +1,32 @@ +# harp-data + +Load Harp register data into pandas DataFrames. This is the package that pulls +in `pandas` — [`harp-protocol`](../harp-protocol) stays numpy-only and exposes a +pandas-free `ColumnData` view that this package assembles into a DataFrame. + +## Read a register from a file + +`parse_to_dataframe` takes a register and a source (path, bytes, or open binary +file) and returns one row per frame: + +```python +from harp.data import parse_to_dataframe +from my_device import AnalogData + +df = parse_to_dataframe(AnalogData, "AnalogData.bin") +df = parse_to_dataframe(AnalogData, raw, timestamp=True, message_type=False, decode_enums=True) +``` + +Enum fields decode to `pd.Categorical` (`decode_enums=False` keeps raw codes). + +## From an already-parsed payload + +If you already have a batched payload (e.g. from `register.parse_bulk`), convert +it directly: + +```python +from harp.data import payload_to_dataframe + +_data, timestamps, _msg, payload = AnalogData.parse_bulk(raw) +df = payload_to_dataframe(payload) +``` diff --git a/src/packages/harp-data/pyproject.toml b/src/packages/harp-data/pyproject.toml new file mode 100644 index 0000000..47bdfff --- /dev/null +++ b/src/packages/harp-data/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "harp-data" +dynamic = ["version"] +description = "Load Harp device data into pandas DataFrames" +requires-python = ">=3.11" +dependencies = [ + "harp-protocol", + "numpy>=1.24", + "pandas>=2.0", +] + +[build-system] +requires = ["setuptools>=77", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +root = "../../.." + +[tool.setuptools.packages.find] +where = ["src"] +include = ["harp*"] diff --git a/src/packages/harp-data/src/harp/data/__init__.py b/src/packages/harp-data/src/harp/data/__init__.py new file mode 100644 index 0000000..c8208f1 --- /dev/null +++ b/src/packages/harp-data/src/harp/data/__init__.py @@ -0,0 +1,6 @@ +from ._reader import parse_to_dataframe, payload_to_dataframe + +__all__ = [ + "parse_to_dataframe", + "payload_to_dataframe", +] diff --git a/src/packages/harp-data/src/harp/data/_reader.py b/src/packages/harp-data/src/harp/data/_reader.py new file mode 100644 index 0000000..b068df4 --- /dev/null +++ b/src/packages/harp-data/src/harp/data/_reader.py @@ -0,0 +1,85 @@ +"""Load Harp register data into pandas DataFrames.""" + +from pathlib import Path +from typing import Any, BinaryIO, Union + +import numpy as np +import pandas as pd +from harp.protocol import RegisterBase + +Source = Union[str, Path, bytes, bytearray, memoryview, BinaryIO] + +_MSG_NAMES = np.array(["_NONE", "Read", "Write", "Event"]) + + +def _read_bytes(source: Source) -> bytes: + if isinstance(source, (bytes, bytearray, memoryview)): + return bytes(source) + if isinstance(source, (str, Path)): + return Path(source).read_bytes() + return source.read() # open binary file / stream + + +_DEFAULT_COLUMN_NAME = "value" + + +def payload_to_dataframe( + payload: Any, *, decode_enums: bool = True, demux_bit_masks: bool = False, copy: bool = False +) -> pd.DataFrame: + """Turn a (batched) payload into a DataFrame, one row per frame. + + ``decode_enums`` relabels enum columns as ``pd.Categorical``; ``demux_bit_masks`` + expands each flag (``BitMask``) column into one boolean column per flag member. + """ + # TODO: we may need to account for cases where columns have the same name. + # this can happen when demuxing bitmasks, for example, where each bitmask column + # is expanded into multiple boolean columns with the same name. + cols = payload.to_columns(decode_enums=decode_enums, demux_bit_masks=demux_bit_masks) + return pd.DataFrame( + { + (c.name if c.name is not None else _DEFAULT_COLUMN_NAME): ( + pd.Categorical.from_codes(c.data, categories=c.categories) + if c.categories is not None + else c.data + ) + for c in cols + }, + copy=copy, + ) + + +def parse_to_dataframe( + register: type[RegisterBase[Any]], + source: Source, + *, + timestamp: bool = True, + message_type: bool = False, + decode_enums: bool = True, + demux_bit_masks: bool = False, +) -> pd.DataFrame: + """Parse all frames of ``register`` from ``source`` into a DataFrame. + + ``source`` may be a file path, raw bytes, or an open binary file object. + ``timestamp`` and ``message_type`` insert leading columns; ``decode_enums`` + controls whether enum fields become ``pd.Categorical`` (True) or raw codes; + ``demux_bit_masks`` expands each flag (``BitMask``) field into one boolean + column per flag member (True) or keeps it as a single raw-integer column. + """ + raw = _read_bytes(source) + _data, timestamps, msg_view, payload = register.parse_bulk(raw, parse_timestamp=timestamp) + df = payload_to_dataframe(payload, decode_enums=decode_enums, demux_bit_masks=demux_bit_masks) + + if message_type and msg_view is not None: + df.insert( + 0, + "message_type", + pd.Categorical(_MSG_NAMES[msg_view & 0x03], categories=_MSG_NAMES[1:]), + ) + if timestamp: + if timestamps is None: + raise ValueError( + "Buffer contains no timestamp data; pass timestamp=False to suppress " + "the timestamp column." + ) + df.insert(0, "timestamp", timestamps) + return df diff --git a/src/packages/harp-data/src/harp/data/py.typed b/src/packages/harp-data/src/harp/data/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/packages/harp-device/README.md b/src/packages/harp-device/README.md new file mode 100644 index 0000000..f4b0f01 --- /dev/null +++ b/src/packages/harp-device/README.md @@ -0,0 +1,35 @@ +# harp-device + +The transport-agnostic device layer for the Harp protocol: the common Harp +registers and a `Device` base that handles framing, request/reply and register +access. It depends only on [`harp-protocol`](../harp-protocol) — no transport +dependencies. Pair it with a transport (e.g. [`harp-serial`](../harp-serial)). + +## Read/write registers + +A `Device` is driven over a transport; `read`/`write` take a register class: + +```python +from harp.device import Device, WhoAmI, OperationControl + +# `device` is a Device opened over some transport (see harp-serial) +who = device.read(WhoAmI).parsed # -> np.uint16 +device.write(OperationControl, payload) # write a register +``` + +## Extending for a specific device + +Downstream (often generated) packages add their registers and spread the core +`REGISTER_MAP`, and may set `__whoami__` for identity validation on connect: + +```python +from harp.device import Device, REGISTER_MAP as _CORE_REGISTER_MAP + +class MyDevice(Device): + __whoami__ = 1216 + +REGISTER_MAP = {**_CORE_REGISTER_MAP, 32: DigitalInputState, ...} +``` + +A new transport is just an object implementing the `ITransport` protocol +(`open`/`write`/`read`/`close`). diff --git a/src/packages/harp-device/pyproject.toml b/src/packages/harp-device/pyproject.toml new file mode 100644 index 0000000..54e8578 --- /dev/null +++ b/src/packages/harp-device/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "harp-device" +dynamic = ["version"] +description = "Transport-agnostic Harp device protocol layer" +requires-python = ">=3.11" +dependencies = [ + "harp-protocol", +] + +[build-system] +requires = ["setuptools>=77", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +root = "../../.." + +[tool.setuptools.packages.find] +where = ["src"] +include = ["harp*"] diff --git a/src/packages/harp-device/src/harp/device/__init__.py b/src/packages/harp-device/src/harp/device/__init__.py new file mode 100644 index 0000000..ac81527 --- /dev/null +++ b/src/packages/harp-device/src/harp/device/__init__.py @@ -0,0 +1,62 @@ +from ._device import Device, EventHandler, Subscription +from ._framer import HarpFramer +from ._registers import ( + AssemblyVersion, + ClockConfiguration, + ClockConfigurationFlags, + ClockConfigurationPayload, + CoreVersionHigh, + CoreVersionLow, + DeviceName, + DeviceNamePayload, + EnableFlag, + FirmwareVersionHigh, + FirmwareVersionLow, + HardwareVersionHigh, + HardwareVersionLow, + OperationControl, + OperationControlPayload, + OperationMode, + ResetDevice, + ResetDevicePayload, + ResetFlags, + SerialNumber, + TimestampMicroseconds, + TimestampSeconds, + WhoAmI, +) +from ._register_map import REGISTER_MAP +from ._transport import ITransport, TransportError + +__all__ = [ + "Device", + "EventHandler", + "Subscription", + "HarpFramer", + "ITransport", + "TransportError", + "REGISTER_MAP", + "WhoAmI", + "HardwareVersionHigh", + "HardwareVersionLow", + "AssemblyVersion", + "CoreVersionHigh", + "CoreVersionLow", + "FirmwareVersionHigh", + "FirmwareVersionLow", + "TimestampSeconds", + "TimestampMicroseconds", + "OperationControl", + "OperationControlPayload", + "OperationMode", + "ResetDevice", + "ResetDevicePayload", + "ResetFlags", + "DeviceName", + "DeviceNamePayload", + "EnableFlag", + "ClockConfiguration", + "ClockConfigurationFlags", + "ClockConfigurationPayload", + "SerialNumber", +] diff --git a/src/packages/harp-device/src/harp/device/_device.py b/src/packages/harp-device/src/harp/device/_device.py new file mode 100644 index 0000000..69adfd7 --- /dev/null +++ b/src/packages/harp-device/src/harp/device/_device.py @@ -0,0 +1,347 @@ +"""Transport-agnostic Harp device base class.""" + +from collections.abc import Callable, Iterable +from typing import Any, ClassVar, Self, TypeVar + +import logging +import queue +import threading + +from harp.protocol import HarpMessage, MessageType +from harp.protocol._message import ParsedHarpMessage +from harp.protocol._register import RegisterBase + +from ._framer import HarpFramer +from ._transport import ITransport, TransportError +from ._registers import ( + WhoAmI, +) + +P = TypeVar("P") + +_logger = logging.getLogger(__name__) + +#: A callback receiving a typed, parsed event for a specific register. +EventHandler = Callable[[ParsedHarpMessage[P]], None] + +#: Message types a subscription reacts to, as a single type or an iterable. +MessageTypeFilter = MessageType | Iterable[MessageType] + +#: Default filter for :meth:`Device.subscribe`: unsolicited events only. +_DEFAULT_MESSAGE_TYPES: frozenset[MessageType] = frozenset({MessageType.Event}) + + +def _normalize_message_types(message_types: MessageTypeFilter) -> frozenset[MessageType]: + if isinstance(message_types, MessageType): + return frozenset({message_types}) + return frozenset(message_types) + + +class Subscription: + """Handle returned by :meth:`Device.subscribe`. Cancel with + :meth:`unsubscribe`, or use as a context manager to auto-cancel on exit.""" + + def __init__( + self, + device: "Device", + address: int | None, + handler: Callable[[Any], None], + message_types: frozenset[MessageType], + ) -> None: + self._device = device + self._address = address # None => catch-all + self._handler = handler + self._message_types = message_types + self._active = True + + def unsubscribe(self) -> None: + """Stop delivering events to this subscription. Idempotent.""" + if self._active: + self._device._remove_subscription(self) + self._active = False + + def __enter__(self) -> "Subscription": + return self + + def __exit__(self, *args: object) -> None: + self.unsubscribe() + + +class Device: + """Harp device protocol logic (framing, request/reply, register access) + over an :class:`~harp.device.ITransport`. + + Must be opened before use, via ``with`` or :meth:`open`. Subclasses add + register class attributes and set :attr:`__whoami__` to validate device + identity on open (``0x0`` skips the check). + """ + + REPLY_TIMEOUT: ClassVar[float] = 5.0 # seconds + + #: Expected ``WhoAmI`` of the device this class models; ``0x0`` skips the check. + __whoami__: ClassVar[int] = 0x0 + + def __init__(self, transport: ITransport, *, raise_on_error: bool = True) -> None: + self._transport = transport + self.raise_on_error = raise_on_error + self._framer = HarpFramer() + self._pending: dict[int, queue.SimpleQueue] = {} + self._pending_lock = threading.Lock() + self._running = False + self._thread: threading.Thread | None = None + + # Event subscriptions, delivered off the reader thread (see _event_loop). + self._subscriptions: dict[int, list[Subscription]] = {} + self._registers: dict[int, type[RegisterBase[Any]]] = {} + self._catch_all: list[Subscription] = [] + self._sub_lock = threading.Lock() + self._event_queue: queue.SimpleQueue[HarpMessage | None] = queue.SimpleQueue() + self._event_thread: threading.Thread | None = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def open(self) -> Self: + """Open the transport, start the reader thread and validate identity.""" + self._transport.open() + self._running = True + self._event_thread = threading.Thread( + target=self._event_loop, daemon=True, name=f"{type(self).__name__}-events" + ) + self._event_thread.start() + self._thread = threading.Thread( + target=self._read_loop, daemon=True, name=f"{type(self).__name__}-reader" + ) + self._thread.start() + try: + self._validate_whoami() + except Exception: + self.close() + raise + return self + + def _validate_whoami(self) -> None: + """Check the device's ``WhoAmI`` against :attr:`__whoami__` (``0x0`` skips).""" + expected = self.__whoami__ + if expected == 0x0: + return + actual = int(self.read(WhoAmI).parsed) + if actual != expected: + raise RuntimeError( + f"WhoAmI mismatch: {type(self).__name__} expected 0x{expected:04x} " + f"but device reported 0x{actual:04x}." + ) + + def close(self) -> None: + self._running = False + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + if self._event_thread is not None: + self._event_queue.put(None) # wake the loop so it can exit + self._event_thread.join(timeout=2.0) + self._event_thread = None + self._transport.close() + with self._sub_lock: + self._subscriptions.clear() + self._registers.clear() + self._catch_all.clear() + + def __enter__(self) -> Self: + if not self._running: + self.open() + return self + + def __exit__(self, *args: object) -> None: + self.close() + + # ------------------------------------------------------------------ + # Register access + # ------------------------------------------------------------------ + + def read( + self, + register: type[RegisterBase[P]], + *, + timestamp: float | None = None, + port: int = 255, + ) -> ParsedHarpMessage[P]: + # Note: ty can't correctly infer the return type, and this is a known issue: + # https://github.com/astral-sh/ty/issues/623 + frame = register.format(message_type=MessageType.Read, timestamp=timestamp, port=port) + msg = self._request(register.address, frame) + return ParsedHarpMessage.from_message(msg, register.parse(msg)) + + def write( + self, + register: type[RegisterBase[P]], + value: Any, + *, + timestamp: float | None = None, + port: int = 255, + ) -> ParsedHarpMessage[P]: + frame = register.format( + value, message_type=MessageType.Write, timestamp=timestamp, port=port + ) + msg = self._request(register.address, frame) + return ParsedHarpMessage.from_message(msg, register.parse(msg)) + + # ------------------------------------------------------------------ + # Events + # ------------------------------------------------------------------ + + def subscribe( + self, + register: type[RegisterBase[P]], + handler: EventHandler[P], + *, + message_types: MessageTypeFilter = MessageType.Event, + ) -> Subscription: + """Call ``handler`` with a typed, parsed :class:`ParsedHarpMessage` each + time the device emits a message for ``register``. + + By default only unsolicited ``Event`` messages are delivered. Pass + ``message_types`` (a :class:`MessageType` or an iterable of them) to also + observe ``Read``/``Write`` replies, e.g. + ``message_types=(MessageType.Event, MessageType.Write)``. + + Handlers run on a single dedicated event thread, shared by *all* + subscribers, so they may block or call back into :meth:`read`/:meth:`write` + without deadlocking the reader or delaying synchronous requests. However, + because that thread is shared, handlers are invoked **sequentially, in + subscription order, one message at a time**: a slow handler delays every + other subscriber and backs up later messages. Keep handlers quick, and + offload heavy work to your own thread or queue. + + Returns a :class:`Subscription`; call :meth:`Subscription.unsubscribe` to + stop. + """ + sub = Subscription(self, register.address, handler, _normalize_message_types(message_types)) + with self._sub_lock: + self._subscriptions.setdefault(register.address, []).append(sub) + self._registers[register.address] = register + return sub + + def subscribe_all( + self, + handler: Callable[[HarpMessage], None], + *, + message_types: MessageTypeFilter = MessageType.Event, + ) -> Subscription: + """Call ``handler`` with the raw :class:`HarpMessage` for every message, + regardless of address, whose type is in ``message_types`` (default: + ``Event`` only). Pass more types for a full-traffic firehose, e.g. a + logger. See :meth:`subscribe` for threading and cancellation semantics.""" + sub = Subscription(self, None, handler, _normalize_message_types(message_types)) + with self._sub_lock: + self._catch_all.append(sub) + return sub + + def _remove_subscription(self, sub: "Subscription") -> None: + with self._sub_lock: + if sub._address is None: + try: + self._catch_all.remove(sub) + except ValueError: + pass + else: + subs = self._subscriptions.get(sub._address) + if subs is not None: + try: + subs.remove(sub) + except ValueError: + pass + if not subs: + del self._subscriptions[sub._address] + self._registers.pop(sub._address, None) + + def _event_loop(self) -> None: + while True: + msg = self._event_queue.get() + if msg is None: # shutdown sentinel + break + self._deliver_event(msg) + + def _deliver_event(self, msg: HarpMessage) -> None: + with self._sub_lock: + subs = list(self._subscriptions.get(msg.address, ())) + register = self._registers.get(msg.address) + catch_all = list(self._catch_all) + + matching = [s for s in subs if msg.message_type in s._message_types] + if matching and register is not None: + try: + parsed = ParsedHarpMessage.from_message(msg, register.parse(msg)) + except Exception: + _logger.exception( + "Failed to parse %r for address 0x%02x", msg.message_type, msg.address + ) + else: + for sub in matching: + self._safe_call(sub._handler, parsed) + + for sub in catch_all: + if msg.message_type in sub._message_types: + self._safe_call(sub._handler, msg) + + @staticmethod + def _safe_call(handler: Callable[[Any], None], arg: Any) -> None: + try: + handler(arg) + except Exception: + _logger.exception("Event handler %r raised", handler) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _read_loop(self) -> None: + while self._running: + try: + chunk = self._transport.read() + except TransportError: + if self._running: + raise + break # expected while shutting down + if not chunk: + continue + + self._framer.feed(chunk) + for msg in self._framer.frames(): + self._dispatch(msg) + + def _dispatch(self, msg: HarpMessage) -> None: + # Fast path: correlate replies to a pending synchronous request. This is + # O(1) and non-blocking, so it should never stall behind a slow subscriber. + # Events are unsolicited and never correlate to requests + if msg.message_type != MessageType.Event: + with self._pending_lock: + q = self._pending.get(msg.address) + if q is not None: + q.put(msg) + + self._event_queue.put(msg) + + def _request(self, address: int, frame: bytes) -> HarpMessage: + q: queue.SimpleQueue = queue.SimpleQueue() + with self._pending_lock: + self._pending[address] = q + try: + self._transport.write(frame) + try: + msg = q.get(timeout=self.REPLY_TIMEOUT) + if msg.has_error and self.raise_on_error: + raise RuntimeError( + f"Device returned error for register address {address} " + f"(0x{address:02x}). Payload: {msg.payload.hex()}" + ) + return msg + except queue.Empty as exc: + raise TimeoutError( + f"No reply from device for register address {address} " + f"within {self.REPLY_TIMEOUT}s" + ) from exc + finally: + with self._pending_lock: + self._pending.pop(address, None) diff --git a/src/packages/harp-device/src/harp/device/_framer.py b/src/packages/harp-device/src/harp/device/_framer.py new file mode 100644 index 0000000..b7ec28f --- /dev/null +++ b/src/packages/harp-device/src/harp/device/_framer.py @@ -0,0 +1,105 @@ +from collections.abc import Iterator +from pathlib import Path + +from harp.protocol._message import HarpMessage, HarpParseError +from harp.protocol._message_type import message_type_from_byte as _validate_message_type + + +class HarpFramer: + """Stateful Harp message stream parser. + + Feed raw bytes incrementally with feed(), then drain complete frames with + next_frame() or by iterating. Suitable for both file parsing and streaming + sources (e.g. serial ports) where data arrives in chunks. + + Recovery: on checksum or PayloadType failure, the framer skips exactly the + bad MessageType byte and retries from the next byte — matching the C# + StreamTransport resynchronisation strategy. + """ + + def __init__(self) -> None: + self._buf: bytearray = bytearray() + self._pos: int = 0 + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def feed(self, data: bytes | bytearray) -> None: + """Append new bytes to the internal buffer.""" + self._buf.extend(data) + # Compact once we've consumed a decent chunk to avoid unbounded growth. + if self._pos > 4096: + del self._buf[: self._pos] + self._pos = 0 + + def next_frame(self) -> HarpMessage | None: + """Return the next complete, valid HarpMessage, or None if not enough data.""" + buf = self._buf + pos = self._pos + + while pos < len(buf): + # ── State 1: Seek ────────────────────────────────────────────── + # Find a byte that looks like a valid MessageType. + try: + _validate_message_type(buf[pos]) + except ValueError: + pos += 1 + continue + + msg_type_pos = pos + + # ── State 2: ReadLength ──────────────────────────────────────── + if pos + 1 >= len(buf): + break # need more data + + length = buf[pos + 1] + if length == 0: + # Length=0 is invalid (no remaining bytes, not even checksum). + pos += 1 + continue + + # ── State 3: ReadBody ────────────────────────────────────────── + frame_end = pos + 2 + length + if frame_end > len(buf): + break # frame not yet complete + + frame = bytes(buf[pos:frame_end]) + try: + msg = HarpMessage.parse(frame) + pos = frame_end + self._pos = pos + return msg + except HarpParseError: + # Recovery: skip the bad MessageType byte, retry from pos+1. + pos = msg_type_pos + 1 + continue + + self._pos = pos + return None + + def frames(self) -> Iterator[HarpMessage]: + """Yield all complete frames currently available in the buffer.""" + while (msg := self.next_frame()) is not None: + yield msg + + def __iter__(self) -> Iterator[HarpMessage]: + return self.frames() + + # ------------------------------------------------------------------ + # Convenience class methods + # ------------------------------------------------------------------ + + @classmethod + def parse_bytes(cls, data: bytes | bytearray) -> list[HarpMessage]: + """Parse all Harp messages from a byte buffer.""" + framer = cls() + framer.feed(data) + return list(framer.frames()) + + @classmethod + def parse_file(cls, path: str | Path) -> list[HarpMessage]: + """Parse all Harp messages from a binary file.""" + with open(path, "rb") as f: + data = f.read() + return cls.parse_bytes(data) diff --git a/src/packages/harp-device/src/harp/device/_register_map.py b/src/packages/harp-device/src/harp/device/_register_map.py new file mode 100644 index 0000000..bc532dc --- /dev/null +++ b/src/packages/harp-device/src/harp/device/_register_map.py @@ -0,0 +1,48 @@ +"""Address → register-class map for the core Harp registers. + +Downstream device packages spread this into their own map:: + + from harp.device import REGISTER_MAP as _CORE_REGISTER_MAP + + REGISTER_MAP = {**_CORE_REGISTER_MAP, 32: DigitalInputState, ...} +""" + +from typing import Any + +from harp.protocol import RegisterBase + +from ._registers import ( + AssemblyVersion, + ClockConfiguration, + CoreVersionHigh, + CoreVersionLow, + DeviceName, + FirmwareVersionHigh, + FirmwareVersionLow, + HardwareVersionHigh, + HardwareVersionLow, + OperationControl, + ResetDevice, + SerialNumber, + TimestampMicroseconds, + TimestampSeconds, + WhoAmI, +) + +REGISTER_MAP: dict[int, type[RegisterBase[Any]]] = { + 0: WhoAmI, + 1: HardwareVersionHigh, + 2: HardwareVersionLow, + 3: AssemblyVersion, + 4: CoreVersionHigh, + 5: CoreVersionLow, + 6: FirmwareVersionHigh, + 7: FirmwareVersionLow, + 8: TimestampSeconds, + 9: TimestampMicroseconds, + 10: OperationControl, + 11: ResetDevice, + 12: DeviceName, + 13: SerialNumber, + 14: ClockConfiguration, +} diff --git a/src/packages/harp-device/src/harp/device/_registers.py b/src/packages/harp-device/src/harp/device/_registers.py new file mode 100644 index 0000000..b489da6 --- /dev/null +++ b/src/packages/harp-device/src/harp/device/_registers.py @@ -0,0 +1,210 @@ +# This file was automatically generated and should not be edited directly. +# To make changes, edit the device metadata and regenerate the interface. + +import enum +from typing import ClassVar + +import numpy as np +from harp.protocol import ( + AnonymousPayload, + BitMask, + BoolConverter, + Field, + GroupMask, + PayloadType, + RegisterBase, + RegisterU16, + RegisterU32, + RegisterU8, + StringConverter, + StructPayload, +) + + +class ResetFlags(enum.IntFlag): + """Specifies the behavior of the non-volatile registers when resetting the device.""" + + RESTORE_DEFAULT = 0x1 + """The device will boot with all the registers reset to their default factory values.""" + RESTORE_EEPROM = 0x2 + """The device will boot and restore all the registers to the values stored in non-volatile memory.""" + SAVE = 0x4 + """The device will boot and save all the current register values to non-volatile memory.""" + RESTORE_NAME = 0x8 + """The device will boot with the default device name.""" + UPDATE_FIRMWARE = 0x20 + """The device will enter firmware update mode.""" + BOOT_FROM_DEFAULT = 0x40 + """Specifies that the device has booted from default factory values.""" + BOOT_FROM_EEPROM = 0x80 + """Specifies that the device has booted from non-volatile values stored in EEPROM.""" + + +class ClockConfigurationFlags(enum.IntFlag): + """Specifies configuration flags for the device synchronization clock.""" + + CLOCK_REPEATER = 0x1 + """The device will repeat the clock synchronization signal to the clock output connector, if available.""" + CLOCK_GENERATOR = 0x2 + """The device resets and generates the clock synchronization signal on the clock output connector, if available.""" + REPEATER_CAPABILITY = 0x8 + """Specifies the device has the capability to repeat the clock synchronization signal to the clock output connector.""" + GENERATOR_CAPABILITY = 0x10 + """Specifies the device has the capability to generate the clock synchronization signal to the clock output connector.""" + CLOCK_UNLOCK = 0x40 + """The device will unlock the timestamp register counter and will accept commands to set new timestamp values.""" + CLOCK_LOCK = 0x80 + """The device will lock the timestamp register counter and will not accept commands to set new timestamp values.""" + + +class OperationMode(enum.IntEnum): + """Specifies the operation mode of the device.""" + + STANDBY = 0 + """Disable all event reporting on the device.""" + ACTIVE = 1 + """Event detection is enabled. Only enabled events are reported by the device.""" + SPEED = 3 + """The device enters speed mode.""" + + +class EnableFlag(enum.IntEnum): + """Specifies whether a specific register flag is enabled or disabled.""" + + DISABLED = 0 + """Specifies that the flag is disabled.""" + ENABLED = 1 + """Specifies that the flag is enabled.""" + + +class OperationControlPayload(StructPayload[np.uint8]): + """Represents the payload of the OperationControl register.""" + + operation_mode: OperationMode = GroupMask(enum=OperationMode, mask=0x3) + """Specifies the operation mode of the device.""" + dump_registers: bool = Field(BoolConverter(), mask=0x8) + """Specifies whether the device should report the content of all registers on initialization.""" + mute_replies: bool = Field(BoolConverter(), mask=0x10) + """Specifies whether the replies to all commands will be muted, i.e. not sent by the device.""" + visual_indicators: EnableFlag = GroupMask(enum=EnableFlag, mask=0x20) + """Specifies the state of all visual indicators on the device.""" + operation_led: EnableFlag = GroupMask(enum=EnableFlag, mask=0x40) + """Specifies whether the device state LED should report the operation mode of the device.""" + heartbeat: EnableFlag = GroupMask(enum=EnableFlag, mask=0x80) + """Specifies whether the device should report the content of the seconds register each second.""" + + +class ResetDevicePayload(AnonymousPayload[np.uint8]): + """Represents the payload of the ResetDevice register.""" + + __value__: ResetFlags = BitMask(enum=ResetFlags) + + +class DeviceNamePayload(AnonymousPayload[np.uint8]): + """Represents the payload of the DeviceName register.""" + + __value__: str = Field(StringConverter(25)) + + +class ClockConfigurationPayload(AnonymousPayload[np.uint8]): + """Represents the payload of the ClockConfiguration register.""" + + __value__: ClockConfigurationFlags = BitMask(enum=ClockConfigurationFlags) + + +class WhoAmI(RegisterU16): + """Specifies the identity class of the device.""" + + address: ClassVar[int] = 0 + + +class HardwareVersionHigh(RegisterU8): + """Specifies the major hardware version of the device.""" + + address: ClassVar[int] = 1 + + +class HardwareVersionLow(RegisterU8): + """Specifies the minor hardware version of the device.""" + + address: ClassVar[int] = 2 + + +class AssemblyVersion(RegisterU8): + """Specifies the version of the assembled components in the device.""" + + address: ClassVar[int] = 3 + + +class CoreVersionHigh(RegisterU8): + """Specifies the major version of the Harp core implemented by the device.""" + + address: ClassVar[int] = 4 + + +class CoreVersionLow(RegisterU8): + """Specifies the minor version of the Harp core implemented by the device.""" + + address: ClassVar[int] = 5 + + +class FirmwareVersionHigh(RegisterU8): + """Specifies the major version of the Harp core implemented by the device.""" + + address: ClassVar[int] = 6 + + +class FirmwareVersionLow(RegisterU8): + """Specifies the minor version of the Harp core implemented by the device.""" + + address: ClassVar[int] = 7 + + +class TimestampSeconds(RegisterU32): + """Stores the integral part of the system timestamp, in seconds.""" + + address: ClassVar[int] = 8 + + +class TimestampMicroseconds(RegisterU16): + """Stores the fractional part of the system timestamp, in microseconds.""" + + address: ClassVar[int] = 9 + + +class OperationControl(RegisterBase[OperationControlPayload]): + """Stores the configuration mode of the device.""" + + address: ClassVar[int] = 10 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = OperationControlPayload + + +class ResetDevice(RegisterBase[ResetFlags]): + """Resets the device and saves non-volatile registers.""" + + address: ClassVar[int] = 11 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = ResetDevicePayload + + +class DeviceName(RegisterBase[str]): + """Stores the user-specified device name.""" + + address: ClassVar[int] = 12 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = DeviceNamePayload + + +class SerialNumber(RegisterU16): + """Specifies the unique serial number of the device.""" + + address: ClassVar[int] = 13 + + +class ClockConfiguration(RegisterBase[ClockConfigurationFlags]): + """Specifies the configuration for the device synchronization clock.""" + + address: ClassVar[int] = 14 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = ClockConfigurationPayload diff --git a/src/packages/harp-device/src/harp/device/_transport.py b/src/packages/harp-device/src/harp/device/_transport.py new file mode 100644 index 0000000..efa4dfc --- /dev/null +++ b/src/packages/harp-device/src/harp/device/_transport.py @@ -0,0 +1,25 @@ +"""Byte transport abstraction for Harp devices.""" + +from typing import Protocol, runtime_checkable + + +class TransportError(Exception): + """Raised by a transport when the underlying byte channel fails.""" + + +@runtime_checkable +class ITransport(Protocol): + """Byte channel a :class:`~harp.device.Device` drives. + + Owns no protocol logic. Failures are reported as :class:`TransportError`. + """ + + def open(self) -> None: ... + + def write(self, data: bytes) -> None: ... + + def read(self) -> bytes: + """Return available bytes, or ``b''`` on idle/timeout.""" + ... + + def close(self) -> None: ... diff --git a/src/packages/harp-device/src/harp/device/py.typed b/src/packages/harp-device/src/harp/device/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/packages/harp-protocol/README.md b/src/packages/harp-protocol/README.md new file mode 100644 index 0000000..1afee4d --- /dev/null +++ b/src/packages/harp-protocol/README.md @@ -0,0 +1,22 @@ +# harp-protocol + +[![PyPI version](https://badge.fury.io/py/harp-protocol.svg)](https://badge.fury.io/py/harp-protocol) + +The Harp Protocol is a binary communication protocol created in order to facilitate and unify the interaction between different devices. It was designed with efficiency and ease of parsing in mind. + +For more detail please check Harp Tech's official documentation [here](https://harp-tech.org/protocol/BinaryProtocol-8bit.html). + +`harp-protocol` provides the building blocks: message framing and the typed register/payload DSL. Each register knows how to build (`format`) and decode (`parse`) its frames. + +```python +import numpy as np +from harp.protocol import HarpMessage, RegisterU16 + +class WhoAmI(RegisterU16): + address = 0 + +frame = WhoAmI.format(np.uint16(1216)) # build a Write frame +value = WhoAmI.parse(HarpMessage.parse(frame)) # -> np.uint16(1216) +``` + +It carries no transport or device logic — see [`harp-device`](../harp-device) for the device layer. diff --git a/src/packages/harp-protocol/pyproject.toml b/src/packages/harp-protocol/pyproject.toml new file mode 100644 index 0000000..c903d03 --- /dev/null +++ b/src/packages/harp-protocol/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "harp-protocol" +dynamic = ["version"] +description = "Library with the base types for Harp protocol usage." +authors = [{ name = "harp-tech", email = "contact@harp-tech.org" }] +license = "MIT" +keywords = ['python', 'harp'] +requires-python = ">=3.11" +dependencies = [ + "numpy>=1.24", + "typing-extensions>=4.0", +] + +[build-system] +requires = ["setuptools>=77", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +root = "../../.." + +[tool.setuptools.packages.find] +where = ["src"] +include = ["harp*"] diff --git a/src/packages/harp-protocol/src/harp/protocol/__init__.py b/src/packages/harp-protocol/src/harp/protocol/__init__.py new file mode 100644 index 0000000..6e2d57e --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/__init__.py @@ -0,0 +1,127 @@ +from ._message import HarpMessage, HarpParseError, ParsedHarpMessage +from ._message_type import MessageType +from ._payload_converters import ( + BoolConverter, + Converter, + EnumConverter, + IdentityConverter, + StringConverter, + HarpVersionConverter, + HarpVersion, +) +from ._payload import ( + PayloadBase, + StructPayload, + Field, + GroupMask, + BitMask, + PayloadU8, + PayloadU16, + PayloadU32, + PayloadU64, + PayloadS8, + PayloadS16, + PayloadS32, + PayloadS64, + PayloadFloat, + PayloadU8Array, + PayloadU16Array, + PayloadU32Array, + PayloadU64Array, + PayloadS8Array, + PayloadS16Array, + PayloadS32Array, + PayloadS64Array, + PayloadFloatArray, + AnonymousPayload, + Column, +) +from ._payload_type import PayloadType +from ._register import ( + RegisterBase, + RegisterU8, + RegisterU16, + RegisterU32, + RegisterU64, + RegisterS8, + RegisterS16, + RegisterS32, + RegisterS64, + RegisterFloat, + RegisterU8Array, + RegisterU16Array, + RegisterU32Array, + RegisterU64Array, + RegisterS8Array, + RegisterS16Array, + RegisterS32Array, + RegisterS64Array, + RegisterFloatArray, +) + +__all__ = [ + # Message type + "MessageType", + # Payload type + "PayloadType", + # Message + "HarpMessage", + "ParsedHarpMessage", + "HarpParseError", + # Converters + "Converter", + "IdentityConverter", + "StringConverter", + "BoolConverter", + "EnumConverter", + "HarpVersionConverter", + "HarpVersion", + # Payload DSL + "PayloadBase", + "StructPayload", + "AnonymousPayload", + "Column", + "Field", + "GroupMask", + "BitMask", + "PayloadU8", + "PayloadU16", + "PayloadU32", + "PayloadU64", + "PayloadS8", + "PayloadS16", + "PayloadS32", + "PayloadS64", + "PayloadFloat", + "PayloadU8Array", + "PayloadU16Array", + "PayloadU32Array", + "PayloadU64Array", + "PayloadS8Array", + "PayloadS16Array", + "PayloadS32Array", + "PayloadS64Array", + "PayloadFloatArray", + # Register DSL + "RegisterBase", + # Register scalar types + "RegisterU8", + "RegisterU16", + "RegisterU32", + "RegisterU64", + "RegisterS8", + "RegisterS16", + "RegisterS32", + "RegisterS64", + "RegisterFloat", + # Register array types + "RegisterU8Array", + "RegisterU16Array", + "RegisterU32Array", + "RegisterU64Array", + "RegisterS8Array", + "RegisterS16Array", + "RegisterS32Array", + "RegisterS64Array", + "RegisterFloatArray", +] diff --git a/src/packages/harp-protocol/src/harp/protocol/_builder.py b/src/packages/harp-protocol/src/harp/protocol/_builder.py new file mode 100644 index 0000000..90e582f --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/_builder.py @@ -0,0 +1,33 @@ +"""Utilities for building outgoing Harp message frames.""" + +import struct + +from ._constants import _DEFAULT_PORT, _TICK_PERIOD_S +from ._message_type import MessageType +from ._message_type import message_type_to_byte as _msg_type_byte +from ._payload_type import PayloadType, encode_payload_type + + +def build_message_frame( + message_type: MessageType, + address: int, + payload_type: PayloadType, + payload: bytes = b"", + *, + port: int = _DEFAULT_PORT, + timestamp: float | None = None, +) -> bytes: + """Build and return a complete Harp wire frame as bytes.""" + if timestamp is not None: + seconds = int(timestamp) + microseconds = round((timestamp - seconds) / _TICK_PERIOD_S) + ts_bytes = struct.pack(" int: + """Wrapping u8 sum of all bytes except the last (the checksum byte itself).""" + return sum(memoryview(data)[:-1]) & 0xFF + + +def validate(data: bytes | bytearray | memoryview) -> bool: + """Return True if the last byte equals the checksum of all preceding bytes.""" + mv = memoryview(data) + if len(mv) < 2: + return False + return compute(mv) == mv[-1] diff --git a/src/packages/harp-protocol/src/harp/protocol/_constants.py b/src/packages/harp-protocol/src/harp/protocol/_constants.py new file mode 100644 index 0000000..0b41c43 --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/_constants.py @@ -0,0 +1,22 @@ +"""Package-wide Harp protocol constants.""" + +# Harp timestamp clock tick period in seconds (32 µs/tick). +_TICK_PERIOD_S: float = 32e-6 + +# Payload-type byte bit that signals a timestamp is present in the frame. +_TIMESTAMP_FLAG: int = 0x10 + +# Default Harp port value (broadcast). +_DEFAULT_PORT: int = 0xFF + +# Fixed header size in bytes: msg_type + length + address + port + payload_type. +_HEADER_LEN: int = 5 + +# Timestamp field size in bytes: 4-byte seconds (u32) + 2-byte microseconds (u16). +_TIMESTAMP_LEN: int = 6 + +# Byte offset of the timestamp microseconds field (_HEADER_LEN + 4). +_TS_MICROS_OFFSET: int = 9 + +# Byte offset of the payload when a timestamp is present (_HEADER_LEN + _TIMESTAMP_LEN). +_TIMESTAMPED_PAYLOAD_OFFSET: int = 11 diff --git a/src/packages/harp-protocol/src/harp/protocol/_message.py b/src/packages/harp-protocol/src/harp/protocol/_message.py new file mode 100644 index 0000000..9192245 --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/_message.py @@ -0,0 +1,170 @@ +"""Harp message container.""" + +import struct +from typing import Generic, TypeVar, cast + +from ._builder import build_message_frame +from ._checksum import validate as _validate_checksum +from ._constants import ( + _DEFAULT_PORT, + _HEADER_LEN, + _TICK_PERIOD_S, + _TIMESTAMP_FLAG, + _TIMESTAMP_LEN, + _TIMESTAMPED_PAYLOAD_OFFSET, +) +from ._message_type import MessageType, _message_type_from_byte_safe +from ._payload_type import PayloadType, decode_payload_type + +P = TypeVar("P") + + +class HarpParseError(Exception): + """An exception raised for errors encountered during message parsing""" + + pass + + +class HarpMessage: + """A Harp message backed by its raw frame bytes. + + Build with the constructor or parse from wire bytes with ``HarpMessage.parse()``. + """ + + __slots__ = ("_bytes",) + + def __init__( + self, + message_type: MessageType, + address: int, + payload_type: PayloadType, + payload: bytes = b"", + *, + port: int = _DEFAULT_PORT, + timestamp: float | None = None, + ) -> None: + self._bytes: bytes = build_message_frame( + message_type, address, payload_type, payload, port=port, timestamp=timestamp + ) + + @classmethod + def parse(cls, data: bytes | bytearray | memoryview) -> "HarpMessage": + """Parse and validate a complete Harp Message from a byte sequence. Raises ``HarpParseError`` on failure.""" + raw = data if isinstance(data, bytes) else bytes(data) + + if len(raw) < 6: + raise HarpParseError(f"Frame too short: {len(raw)} bytes (minimum 6)") + + if not _validate_checksum(raw): + raise HarpParseError("Checksum mismatch") + + # Validate MessageType byte (bits 7,6,5,4,2 must be 0; bits 1:0 are type) + b0 = raw[0] + if _message_type_from_byte_safe(b0) is None: + raise HarpParseError(f"Invalid MessageType byte: 0x{b0:02x}") + + length = raw[1] + if len(raw) != length + 2: + raise HarpParseError(f"Length field {length} inconsistent with buffer size {len(raw)}") + + try: + decode_payload_type(raw[4]) + except ValueError as exc: + raise HarpParseError(str(exc)) from exc + + if bool(raw[4] & _TIMESTAMP_FLAG) and len(raw) < _HEADER_LEN + _TIMESTAMP_LEN + 1: + raise HarpParseError("Frame too short to contain timestamp") + + obj = cls.__new__(cls) + obj._bytes = raw + return obj + + @property + def message_type(self) -> MessageType: + """Return the MessageType of this message.""" + return MessageType(self._bytes[0] & 0x03) + + @property + def has_error(self) -> bool: + """Return True if the error flag is set in this message.""" + return bool(self._bytes[0] & 0x08) + + @property + def address(self) -> int: + """Return the address byte of this message.""" + return self._bytes[2] + + @property + def port(self) -> int: + """Return the port byte of this message.""" + return self._bytes[3] + + @property + def payload_type(self) -> PayloadType: + """Return the PayloadType of this message.""" + return decode_payload_type(self._bytes[4]).payload_type + + @property + def has_timestamp(self) -> bool: + """Return True if the timestamp flag is set in this message.""" + return bool(self._bytes[4] & _TIMESTAMP_FLAG) + + @property + def timestamp(self) -> float | None: + """Return the timestamp of this message, or None if not present.""" + if not self.has_timestamp: + return None + seconds, microseconds = struct.unpack_from(" memoryview: + """Payload bytes, excluding timestamp and checksum.""" + offset = _TIMESTAMPED_PAYLOAD_OFFSET if self.has_timestamp else _HEADER_LEN + return memoryview(self._bytes)[offset:-1] + + @property + def bytes(self) -> bytes: + """The complete raw message frame, including checksum.""" + return self._bytes + + def __str__(self) -> str: + return ( + f"HarpMessage(message_type={self.message_type!r}, address={self.address:#04x}, " + f"payload_type={self.payload_type!r}, timestamp={self.timestamp!r})" + ) + + +class ParsedHarpMessage(HarpMessage, Generic[P]): + """A ``HarpMessage`` with a typed parsed payload attached.""" + + __slots__ = ("_parsed",) + + def __init__( + self, + message_type: MessageType, + address: int, + payload_type: PayloadType, + payload: bytes = b"", + *, + port: int = _DEFAULT_PORT, + timestamp: float | None = None, + parsed: P, + ) -> None: + super().__init__( + message_type, address, payload_type, payload, port=port, timestamp=timestamp + ) + self._parsed = parsed + + @classmethod + def from_message(cls, msg: HarpMessage, parsed: P) -> "ParsedHarpMessage[P]": + """Wrap a ``HarpMessage`` with a pre-parsed payload.""" + obj = cls.__new__(cls) + obj._bytes = msg.bytes + obj._parsed = parsed + return obj + + @property + def parsed(self) -> P: + """Returns the parsed payload.""" + return self._parsed diff --git a/src/packages/harp-protocol/src/harp/protocol/_message_type.py b/src/packages/harp-protocol/src/harp/protocol/_message_type.py new file mode 100644 index 0000000..6ac97dd --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/_message_type.py @@ -0,0 +1,40 @@ +from enum import IntEnum + + +class MessageType(IntEnum): + """Represents the a message type from the harp protocol""" + + Read = 1 + Write = 2 + Event = 3 + + +# Bits 7,6,5,4,2 must be 0; bit 3 is error; bits 1:0 are type. +_RESERVED_MASK = 0b11110100 +_VALID_TYPES = frozenset(t.value for t in MessageType) + + +def _message_type_from_byte_safe(b: int) -> "tuple[MessageType, bool] | None": + """Decode a MessageType byte into ``(MessageType, has_error)``, or ``None`` if invalid.""" + if b & _RESERVED_MASK: + return None + type_bits = b & 0x03 + if type_bits not in _VALID_TYPES: + return None + return MessageType(type_bits), bool(b & 0x08) + + +def message_type_from_byte(b: int) -> tuple["MessageType", bool]: + """Decode a MessageType byte into ``(MessageType, has_error)``. Raises ``ValueError`` on invalid input.""" + result = _message_type_from_byte_safe(b) + if result is None: + type_bits = b & 0x03 + if b & _RESERVED_MASK: + raise ValueError(f"Reserved bits set in MessageType byte: 0x{b:02x}") + raise ValueError(f"Invalid MessageType value {type_bits} in byte: 0x{b:02x}") + return result + + +def message_type_to_byte(message_type: MessageType, has_error: bool = False) -> int: + """Encode MessageType + error flag to a single byte.""" + return message_type.value | (0x08 if has_error else 0) diff --git a/src/packages/harp-protocol/src/harp/protocol/_payload.py b/src/packages/harp-protocol/src/harp/protocol/_payload.py new file mode 100644 index 0000000..22e5c74 --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/_payload.py @@ -0,0 +1,1108 @@ +import enum +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Generic, + Protocol, + TypeVar, + final, + get_args, + overload, +) + +import numpy as np +from numpy.typing import NDArray +from typing_extensions import Self, Sentinel, dataclass_transform + +from ._payload_converters import Converter as _Converter +from ._payload_converters import IdentityConverter as _IdentityConverter + +NpStructT = TypeVar("NpStructT", bound=np.generic) +T = TypeVar("T") +E = TypeVar("E", bound=enum.IntEnum) +F = TypeVar("F", bound=enum.IntFlag) + +_MISSING = Sentinel("_MISSING") +_DEFAULT_ELEMENT = np.dtype(np.uint8) + + +@dataclass(frozen=True, slots=True, eq=False) +class Column: + """One column of a batched payload. + + ``data`` is a 1-D numpy array (one row per frame). When ``categories`` is + not ``None`` the column is enum-backed: ``data`` holds integer category + *codes* and ``categories`` the ordered labels, so a consumer can map codes + to labels without copying. + + ``eq=False`` keeps identity comparison — field-wise equality would hit + numpy's ambiguous-truth-value error on the ``data`` array. + + ``name`` is ``None`` for an anonymous single value + """ + + name: str | None + data: NDArray[Any] + categories: Any | None = None + + +@dataclass(frozen=True) +class _FieldSlot: + """One physical numpy field: its dtype and byte offset within the record.""" + + dtype: np.dtype + byte_offset: int + + +def _mask_trailing_zeros(mask: int) -> int: + """Number of trailing zero bits in ``mask`` — the right-shift that aligns a + masked field to bit 0.""" + if mask == 0: + return 0 + return (mask & -mask).bit_length() - 1 + + +# --------------------------------------------------------------------------- +# Descriptors — scalar variants (return Python / 0-D types) +# --------------------------------------------------------------------------- + + +class Field(Generic[T]): + """Descriptor for a payload view decoded through a :class:`Converter`. + + Two modes, selected by ``mask``: + + * **Whole-element** (``mask=None``, the default) — the view reads + ``converter.dtype.itemsize`` bytes starting at ``offset`` (in base-element + units; see :class:`StructPayload`) and runs them through ``converter``. The + converter owns its own ``dtype`` (byte layout) and is independent of the + payload's base element type, so the same converter works under any register + width. + * **Masked sub-field** (``mask`` set) — the raw value is extracted as + ``(element & mask) >> shift`` from the payload's *base element* at ``offset`` + and then run through ``converter`` (which dictates the output type). The + right-shift is derived from ``mask`` (its trailing-zero count). Several masked + fields at the same offset share the element slot automatically, and may share + it with a :class:`GroupMask` or :class:`BitMask` on the same word. + + ``offset`` defaults to ``0``. Omitting it suits a payload with a single + member; when a payload has several distinct slots, each must declare an + explicit ``offset=`` or the overlap check rejects the layout. + """ + + if TYPE_CHECKING: + # Makes `field: T = Field(converter=...)` valid under @dataclass_transform without a + # type-mismatch error. At runtime __new__ is not defined and a Field instance is + # returned as normal. + def __new__( # type: ignore[misc] + cls, + converter: "_Converter[T]", + *, + mask: int | None = None, + offset: int = 0, + default: "T" = ..., + ) -> "T": ... + + def __init__( + self, + converter: _Converter[T], + *, + mask: int | None = None, + offset: int = 0, + default: object = _MISSING, + ) -> None: + """Instantiates a new payload Field.""" + self._converter = converter + self._mask = mask + self._shift = _mask_trailing_zeros(mask) if mask is not None else 0 + self._offset = offset + self._default = default + # Numpy slot this field reads/writes; assigned in PayloadBase.__init_subclass__. + self._slot: str = "" + self._dtype: np.dtype = _DEFAULT_ELEMENT + + def _encode_value(self, value: Any) -> int: + """Map a user value back to the integer to be masked + shifted into the slot + (masked variant only).""" + tmp = np.zeros((), dtype=self._converter.dtype) + self._converter.encode_into(tmp, value) + return int(tmp) + + def _bind_slot(self, slot: str, elem: np.dtype) -> None: + """Bind this field to its numpy ``slot`` (called by ``_build_struct_dtype``).""" + self._slot = slot + if self._mask is not None: # masked sub-field reads the base element + self._dtype = elem + + @overload + def __get__(self, obj: None, owner: object = None) -> "Field[T]": ... + @overload + def __get__(self, obj: "PayloadBase", owner: object = None) -> T: ... + def __get__(self, obj: "PayloadBase | None", owner: object = None) -> Any: + if obj is None: + return self + if self._mask is not None: + raw = (obj._arr[self._slot] & self._mask) >> self._shift + return self._converter.decode_scalar(self._converter.dtype.type(raw)) + return self._converter.decode_scalar(obj._arr[self._slot]) # pyright: ignore[reportArgumentType] + + def _to_batch(self) -> "_FieldBatch[T]": + """Returns the metadata for the corresponding Batch type""" + return _FieldBatch( + converter=self._converter, + mask=self._mask, + slot=self._slot, + dtype=self._dtype, + ) + + def _columns( + self, arr: "NDArray[Any]", name: "str | None", *, decode_enums: bool, demux_bit_masks: bool + ) -> "list[Column]": + """Render this field as one or more batched :class:`Column`s (see ``to_columns``). + + ``name`` is the column name, or ``None`` for an anonymous root value (the + consuming package decides the fallback label).""" + if self._mask is not None: # masked numeric sub-field + raw = (arr[self._slot] & self._mask) >> self._shift + return [Column(name, self._converter.decode_batch(raw.astype(self._converter.dtype)))] + if not isinstance(self._converter, _IdentityConverter): # whole-element, decoded + return [Column(name, self._converter.decode_batch(arr[self._slot]))] + sub = arr[self._slot] # whole-element, raw passthrough + if sub.ndim <= 1: + return [Column(name, sub)] + # sub-array -> one column per element; index is intrinsic identity, so a + # nameless (root) array is positional, a named field is prefixed. + flat = sub.reshape(len(arr), -1) + label = (lambda i: str(i)) if name is None else (lambda i: f"{name}_{i}") + return [Column(label(i), flat[:, i]) for i in range(flat.shape[1])] + + +def _build_enum_lookup(enum_cls: type[enum.IntEnum]) -> "tuple[list[str], np.ndarray]": + """Helper for GroupMask to build the category list and code lookup table for a given enum.IntEnum class.""" + members = list(enum_cls) + categories = [m.name for m in members] + max_val = max(int(m) for m in members) + code_dtype = np.int8 if len(members) < 128 else np.int32 + code_lookup = np.full(max_val + 1, -1, dtype=code_dtype) + for code, m in enumerate(members): + code_lookup[int(m)] = code + return categories, code_lookup + + +class GroupMask(Generic[E]): + """Descriptor for a masked, shifted enum sub-field of a payload element. + + Syntactic sugar over a masked :class:`Field`: the raw value is extracted as + ``(element & mask) >> shift`` and mapped strictly to an ``enum.IntEnum`` member + (an unknown code raises). ``enum=`` is required; for masked *numeric* fields use + ``Field(converter=..., mask=...)`` instead. + + The right-shift is always derived from ``mask`` (its trailing-zero count, so the + field aligns to bit 0); ``offset`` defaults to ``0``. The element width and + storage slot are derived from the payload's base element type, so several masked + fields at the same offset share storage automatically. + """ + + if TYPE_CHECKING: + # enum variant -> the field type is the enum + def __new__( # type: ignore[misc] # noqa: E704 + cls, *, mask: int, enum: "type[E]", offset: int = 0, default: "E" = ... + ) -> "E": ... + + def __init__( + self, + *, + mask: int, + enum: type[E], + offset: int = 0, + default: object = _MISSING, + ) -> None: + """Instantiates a GroupMask field for the payload""" + if enum is None: + raise TypeError( + "GroupMask requires 'enum'; use Field(converter=..., mask=...) for " + "masked numeric sub-fields" + ) + self._mask = mask + self._shift = _mask_trailing_zeros(mask) + self._enum = enum + self._offset = offset + self._default = default + # Numpy slot assigned in PayloadBase.__init_subclass__. + self._slot: str = "" + self._dtype: np.dtype = _DEFAULT_ELEMENT + self._categories, self._code_lookup = _build_enum_lookup(enum) + + def _decode_raw(self, raw: Any) -> Any: + """Map an extracted (already masked + shifted) integer to its enum member.""" + return self._enum(int(raw)) + + def _encode_value(self, value: Any) -> int: + """Map a user value back to the integer to be masked + shifted into the slot.""" + return int(value) + + def _bind_slot(self, slot: str, elem: np.dtype) -> None: + """Bind this group mask to its shared numpy ``slot``.""" + self._slot = slot + self._dtype = elem + + @overload + def __get__(self, obj: None, owner: object = None) -> "GroupMask[E]": ... + @overload + def __get__(self, obj: "PayloadBase", owner: object = None) -> E: ... + def __get__(self, obj: "PayloadBase | None", owner: object = None) -> Any: + if obj is None: + return self + raw = (obj._arr[self._slot] & self._mask) >> self._shift + return self._decode_raw(raw) + + def _to_batch(self) -> "_GroupMaskBatch[E]": + """Returns the metadata for the corresponding batch type""" + return _GroupMaskBatch( + self._mask, + self._enum, + slot=self._slot, + dtype=self._dtype, + ) + + def _columns( + self, arr: "NDArray[Any]", name: "str | None", *, decode_enums: bool, demux_bit_masks: bool + ) -> "list[Column]": + """One enum column: category codes + labels (``decode_enums``) or raw codes.""" + raw = (arr[self._slot] & self._mask) >> self._shift + if decode_enums: + return [Column(name, self._code_lookup[raw], self._categories)] + return [Column(name, raw)] + + +class BitMask(Generic[F]): + """Descriptor for a masked ``enum.IntFlag`` view of a payload element. + + The flag counterpart of :class:`GroupMask`: the raw value is extracted as + ``element & mask`` and mapped to an ``enum.IntFlag`` member (decoding is + *permissive* — combined flag values such as ``A | B`` are valid, matching the + C# generator's unchecked cast). ``enum=`` is required and must be an + ``IntFlag`` subclass. + + Unlike :class:`GroupMask` there is **no shift**: ``IntFlag`` member values are + absolute bit positions, so the flags are read and written in place. ``mask`` + defaults to the full base element (the common whole-register bitMask case) and + may be narrowed to embed a flag set inside a wider element. The element width + and storage slot are derived from the payload's base element type, so several + masked fields at the same offset share storage automatically. + """ + + if TYPE_CHECKING: + # flag variant -> the field type is the IntFlag + def __new__( # type: ignore[misc] # noqa: E704 + cls, *, enum: "type[F]", mask: int | None = None, offset: int = 0, default: "F" = ... + ) -> "F": ... + + def __init__( + self, + *, + enum: type[F], + mask: int | None = None, + offset: int = 0, + default: object = _MISSING, + ) -> None: + """Instantiates a BitMask field for the payload""" + if enum is None: + raise TypeError("BitMask requires 'enum' (an enum.IntFlag subclass)") + # mask=None is resolved to the full base element in _build_struct_dtype. + self._mask = mask + self._shift = 0 # IntFlag members are absolute bit positions; never shifted + self._enum = enum + self._offset = offset + self._default = default + # Numpy slot assigned in PayloadBase.__init_subclass__. + self._slot: str = "" + self._dtype: np.dtype = _DEFAULT_ELEMENT + + def _decode_raw(self, raw: Any) -> Any: + """Map an extracted (masked) integer to its IntFlag value (permissive).""" + return self._enum(int(raw)) + + def _encode_value(self, value: Any) -> int: + """Map a user value back to the integer to be masked into the slot.""" + return int(value) + + def _bind_slot(self, slot: str, elem: np.dtype) -> None: + """Bind this flag mask to its shared numpy ``slot``; default the mask to the full element.""" + self._slot = slot + self._dtype = elem + if self._mask is None: + self._mask = (1 << (elem.itemsize * 8)) - 1 + + @overload + def __get__(self, obj: None, owner: object = None) -> "BitMask[F]": ... + @overload + def __get__(self, obj: "PayloadBase", owner: object = None) -> F: ... + def __get__(self, obj: "PayloadBase | None", owner: object = None) -> Any: + if obj is None: + return self + raw = obj._arr[self._slot] & self._mask + return self._decode_raw(raw) + + def _to_batch(self) -> "_BitMaskBatch[F]": + """Returns the metadata for the corresponding batch type""" + assert self._mask is not None # _bind_slot ensures every masked field has a mask + return _BitMaskBatch( + self._mask, + self._enum, + slot=self._slot, + dtype=self._dtype, + ) + + def _columns( + self, arr: "NDArray[Any]", name: "str | None", *, decode_enums: bool, demux_bit_masks: bool + ) -> "list[Column]": + """A single raw-integer column, or (``demux_bit_masks``) one bool column per flag member.""" + assert self._mask is not None # _bind_slot ensures every masked field has a mask + raw = arr[self._slot] & self._mask + if not demux_bit_masks: + return [Column(name, raw)] + # One boolean column per flag member that fits the field. + return [ + Column(member.name, (raw & int(member)) != 0) + for member in self._enum + if not (int(member) & ~self._mask) # skip bits that can't fit the field + ] + + +# --------------------------------------------------------------------------- +# Descriptors — batch variants (return ndarray views) +# These are mostly used for batch operations like `to_dataframe` +# --------------------------------------------------------------------------- + + +class _FieldBatch(Generic[T]): + """Same as _Field but returns an NDArray view for batch payloads rather than a scalar value.""" + + def __init__( + self, + *, + converter: _Converter[T], + mask: int | None = None, + slot: str = "", + dtype: "np.dtype | str | type" = np.uint8, + ) -> None: + self._converter = converter + self._mask = mask + self._shift = _mask_trailing_zeros(mask) if mask is not None else 0 + self._slot = slot + self._dtype = np.dtype(dtype) + + @overload + def __get__(self, obj: None, owner: object = None) -> "_FieldBatch[T]": ... + @overload + def __get__(self, obj: "PayloadBase", owner: object = None) -> "NDArray[Any]": ... + def __get__(self, obj: "PayloadBase | None", owner: object = None) -> Any: + if obj is None: + return self + if self._mask is not None: + raw = (obj._arr[self._slot] & self._mask) >> self._shift + return self._converter.decode_batch(raw.astype(self._converter.dtype)) + return self._converter.decode_batch(obj._arr[self._slot]) + + +class _BitMaskBatch(Generic[F]): + """Same as BitMask but returns an NDArray view for batch payloads rather than a scalar value.""" + + def __init__( + self, + mask: int, + enum: type[F], + *, + slot: str = "", + dtype: "np.dtype | str | type" = np.uint8, + ) -> None: + self._mask = mask + self._shift = 0 + self._enum = enum + self._slot = slot + self._dtype = np.dtype(dtype) + + @overload + def __get__(self, obj: None, owner: object = None) -> "_BitMaskBatch[F]": ... + @overload + def __get__(self, obj: "PayloadBase", owner: object = None) -> "NDArray[Any]": ... + def __get__(self, obj: "PayloadBase | None", owner: object = None) -> Any: + if obj is None: + return self + return obj._arr[self._slot] & self._mask + + +class _GroupMaskBatch(Generic[E]): + """Same as GroupMask but returns an NDArray view for batch payloads rather than a scalar value.""" + + def __init__( + self, + mask: int, + enum: type[E], + *, + slot: str = "", + dtype: "np.dtype | str | type" = np.uint8, + ) -> None: + self._mask = mask + self._shift = _mask_trailing_zeros(mask) + self._enum = enum + self._slot = slot + self._dtype = np.dtype(dtype) + self._categories, self._code_lookup = _build_enum_lookup(enum) + + @overload + def __get__(self, obj: None, owner: object = None) -> "_GroupMaskBatch[E]": ... + @overload + def __get__(self, obj: "PayloadBase", owner: object = None) -> "NDArray[Any]": ... + def __get__(self, obj: "PayloadBase | None", owner: object = None) -> Any: + if obj is None: + return self + # Enum batches return raw integer codes (see to_columns for label decoding). + return (obj._arr[self._slot] & self._mask) >> self._shift + + +_PT = TypeVar("_PT", bound="PayloadBase[Any]", covariant=True) +_MISSING_INIT = Sentinel("_MISSING_INIT") + + +class Batch(Protocol[_PT]): + """Alias type for batched payloads so we can have nice type-hinting + for batch operations like `read_frames` and `to_dataframe`. + + Statically, ``Batch[P]`` is a distinct type from ``P`` so the type + checker knows ``read_frames`` returns an ndarray-shaped view rather + than a single record. At runtime, the value is the auto-derived + ``P.Batch`` sibling whose descriptors return ``NDArray`` views. + + Per-field dtype precision is intentionally dropped — every declared + field reports ``NDArray[Any]`` — to keep ``RegisterBase[P]`` + parameterized by a single TypeVar. + """ + + raw_payload: "NDArray[Any]" + value: "NDArray[Any]" + + def __len__(self) -> int: ... # type: ignore[empty-body] + + def to_columns( # type: ignore[empty-body] + self, *, decode_enums: bool = True, demux_bit_masks: bool = False + ) -> "list[Column]": ... + + def __getattr__(self, name: str) -> "NDArray[Any]": ... # type: ignore[empty-body] + + +# Helpers for type checking using isinstance() +_SCALAR_DECLARATION_TYPES = (Field, GroupMask, BitMask) +_BATCH_DECLARATION_TYPES = (_FieldBatch, _GroupMaskBatch, _BitMaskBatch) +_DECLARATION_TYPES = _SCALAR_DECLARATION_TYPES + _BATCH_DECLARATION_TYPES + + +# value/raw_payload deliberately omitted: overriding them is the intended +# pattern for single-slot converter-driven payloads. +_RESERVED_FIELD_NAMES = frozenset({"_arr", "_dtype", "_repr_fields", "Batch"}) + + +def _batch_init_disabled(self: "PayloadBase", *args: object, **kwargs: object) -> None: + raise TypeError( + f"{type(self).__name__} is a Batch payload; construct it via " + f"from_array()/from_buffer() (or use its scalar twin " + f"{type(self)._scalar_cls.__name__!s})." + ) + + +def _resolve_element_dtype(cls: type) -> np.dtype: + """Resolve the base element dtype from the ``StructPayload[...]`` type arg. + + Only used for offset→byte arithmetic and the masked-read integer width. + Defaults to uint8 (byte) when the payload is not parameterized, which makes + byte-offset layouts of heterogeneous consecutive fields work out of the box. + """ + for base in getattr(cls, "__orig_bases__", ()): + for arg in get_args(base): + if isinstance(arg, TypeVar): + continue + try: + return np.dtype(arg) + except TypeError: + continue + return _DEFAULT_ELEMENT + + +def _validate_mask_fits(cls: type, name: str, mask: int, elem: np.dtype) -> None: + if mask < 0 or mask >= (1 << (elem.itemsize * 8)): + raise TypeError( + f"{cls.__name__}: mask {mask:#x} on {name!r} does not fit the base " + f"element {elem} ({elem.itemsize} byte(s))" + ) + + +def _validate_no_overlap(cls: type, slots: "dict[str, _FieldSlot]", itemsize: int) -> None: + """Validate that declared fields do not overlap and fit within the payload itemsize. Overlap is only + allowed for masked fields sharing the same slot.""" + spans = sorted( + (slot.byte_offset, slot.byte_offset + slot.dtype.itemsize, name) + for name, slot in slots.items() + ) + for start, end, name in spans: + if end > itemsize: + raise TypeError( + f"{cls.__name__}: field {name!r} ends at byte {end}, beyond itemsize " + f"{itemsize}; declare an explicit length=" + ) + for (prev_start, prev_end, prev_name), (start, end, name) in zip(spans, spans[1:]): + if start < prev_end: + raise TypeError( + f"{cls.__name__}: fields {prev_name!r} and {name!r} overlap " + f"(bytes [{prev_start},{prev_end}) vs [{start},{end})); masked sub-fields " + f"of the same element must share an offset" + ) + + +def _build_struct_dtype( + cls: "type[PayloadBase]", + declarations: "list[tuple[str, Field | GroupMask | BitMask]]", + length: int | None, +) -> np.dtype: + """Build the numpy structured dtype from field declarations. + + Each descriptor binds itself to its numpy slot via ``_bind_slot``; this + function resolves only cross-field layout — which masked fields share a slot, + plus offsets, overlap, and itemsize.""" + elem = cls._elem_dtype + elem_size = elem.itemsize + slots: dict[str, _FieldSlot] = {} + mask_slot_by_byte_offset: dict[int, str] = {} + + for attr_name, val in declarations: + byte_offset = val._offset * elem_size + # A plain Field (no mask=) is the only whole-element view; everything else + # (GroupMask, BitMask, or a Field with mask=) is a masked sub-field. The + # isinstance form lets the type checker narrow `val` to access ``_converter``. + if isinstance(val, Field) and val._mask is None: # whole-element Field — own slot + if attr_name in slots: + raise TypeError(f"{cls.__name__}: duplicate field name {attr_name!r}") + val._bind_slot(attr_name, elem) + slots[attr_name] = _FieldSlot(val._converter.dtype, byte_offset) + else: # masked sub-field — shares the base-element slot at its offset + owner = mask_slot_by_byte_offset.setdefault(byte_offset, attr_name) + val._bind_slot(owner, elem) + assert val._mask is not None # _bind_slot ensures every masked field has a mask + _validate_mask_fits(cls, attr_name, val._mask, elem) + if owner == attr_name: + slots[attr_name] = _FieldSlot(elem, byte_offset) + + if length is not None: + itemsize = length * elem_size + else: + itemsize = max(slot.byte_offset + slot.dtype.itemsize for slot in slots.values()) + _validate_no_overlap(cls, slots, itemsize) + return np.dtype( + { + "names": list(slots), + "formats": [slot.dtype for slot in slots.values()], + "offsets": [slot.byte_offset for slot in slots.values()], + "itemsize": itemsize, + } + ) + + +class PayloadBase(Generic[NpStructT]): + """Base class for typed Harp register payloads. + + A subclass declares fields via the scalar descriptors above. + ``__init_subclass__`` auto-derives a ``Batch`` sibling subclass with the + same dtype but each descriptor swapped to a Batch variant returning an + ``NDArray`` view. ``from_array`` routes by ``ndim`` so callers never need + to mention the Batch class explicitly: 0-D records stay scalar, 1-D + buffers become Batch. + """ + + # Structured numpy dtype describing the memory layout of a single payload record. + dtype: ClassVar[np.dtype] + # Field names shown in __repr__ and used as the column order. + _repr_fields: ClassVar[tuple[str, ...]] + # The scalar twin of this class (identity for scalar classes, points to scalar from Batch). + _scalar_cls: ClassVar["type[PayloadBase]"] + # The batch twin of this class (identity until the Batch sibling is generated). + _batch_cls: ClassVar["type[PayloadBase]"] + # Cached map of attribute name → default value for fields that declare one. + _defaults: ClassVar[dict[str, Any]] + # Auto-generated sibling class whose descriptors return NDArray views instead of scalars. + Batch: ClassVar["type[PayloadBase]"] + # Base element dtype (from the ``StructPayload[...]`` type arg); governs offset + # arithmetic and the integer width used for masked reads. Defaults to uint8. + _elem_dtype: ClassVar[np.dtype] = _DEFAULT_ELEMENT + # The ``__value__`` field of an AnonymousPayload root, else None: ``parse`` unwraps to it. + _single_member: ClassVar[str | None] = None + # The underlying numpy array holding one (0-D) or many (1-D) payload records. + _arr: NDArray[NpStructT] + + def __init__(self, *args: object, **kwargs: object) -> None: + cls = type(self) + names = self.dtype.names + if names is None: + raise TypeError(f"{type(self).__name__}.dtype has no named fields") + + # TODO this is just to allow; PayloadU16(foo) syntax. Not sure if it is worth it? + if args and kwargs: + raise TypeError( + f"{cls.__name__}() does not accept positional and keyword args together" + ) + if args: + if len(args) != 1 or len(names) != 1: + raise TypeError(f"{cls.__name__}() takes exactly one positional argument") + kwargs = {names[0]: args[0]} + + defaults = cls._defaults + if defaults: + merged = {k: v for k, v in defaults.items() if k not in kwargs} + if merged: + merged.update(kwargs) + kwargs = merged + + arr = np.zeros((), dtype=self.dtype) + + # Route each kwarg by its descriptor kind, not by whether its name happens + # to match a numpy slot — masked descriptors may share a slot whose name + # collides with the first masked field's attribute name. + for attr_name, value in kwargs.items(): + desc = cls._mro_descriptor(attr_name) + if isinstance(desc, Field) and desc._mask is None: # whole-element Field + desc._converter.encode_into(arr[desc._slot], value) + elif isinstance(desc, (GroupMask, BitMask, Field)): + # masked sub-field -> encode, shift, merge into the shared slot + mask = desc._mask + assert mask is not None # invariant for masked descriptors + mask_in_dtype = np.array(mask, dtype=desc._dtype) + int_val = desc._encode_value(value) + shifted = np.array((int_val << desc._shift) & mask, dtype=desc._dtype) + arr[desc._slot] = (arr[desc._slot] & ~mask_in_dtype) | shifted + elif attr_name in names: + arr[attr_name] = value # raw slot with no descriptor + else: + raise TypeError(f"{cls.__name__}() got unexpected kwarg: {attr_name!r}") + + self._arr = arr + + @classmethod + def _mro_descriptor(cls, name: str) -> "Field[Any] | GroupMask[Any] | BitMask[Any] | None": + for klass in cls.__mro__: + if name in klass.__dict__: + return klass.__dict__[name] + return None + + @classmethod + def _collect_defaults(cls) -> "dict[str, Any]": + """Collect all members with defined default values""" + out: dict[str, Any] = {} + for klass in reversed(cls.__mro__): + for attr, val in klass.__dict__.items(): + if isinstance(val, (Field, GroupMask, BitMask)) and val._default is not _MISSING: + out[attr] = val._default + return out + + @classmethod + def _collect_repr_fields(cls) -> "tuple[str, ...]": + """Declared attribute names in MRO + definition order. + + Covers plain ``Field``s and masked sub-fields alike (a single dtype slot + may back several bitfields, so this enumerates declarations rather than + ``dtype.names``). + """ + names: list[str] = [] + for klass in reversed(cls.__mro__): + for attr, val in klass.__dict__.items(): + if isinstance(val, _SCALAR_DECLARATION_TYPES) and attr not in names: + names.append(attr) + return tuple(names) + + def __init_subclass__( + cls, + *, + _batch_of: "type[PayloadBase] | None" = None, + length: int | None = None, + **kwargs: object, + ) -> None: + super().__init_subclass__(**kwargs) + + if _batch_of is not None: + # Auto-generated Batch sibling: borrow dtype/_repr_fields from its + # scalar twin and wire the scalar↔batch pointers. + cls.dtype = _batch_of.dtype + cls._repr_fields = _batch_of._repr_fields + cls._elem_dtype = _batch_of._elem_dtype + cls._single_member = _batch_of._single_member + cls._scalar_cls = _batch_of + cls._batch_cls = cls + _batch_of._batch_cls = cls + return + + cls._elem_dtype = _resolve_element_dtype(cls) + cls._single_member = None + + for name, val in cls.__dict__.items(): + if isinstance(val, _DECLARATION_TYPES) and name in _RESERVED_FIELD_NAMES: + raise TypeError(f"{cls.__name__}: field name {name!r} is reserved by PayloadBase") + + own_declarations = [ + (name, val) + for name, val in cls.__dict__.items() + if isinstance(val, _SCALAR_DECLARATION_TYPES) + ] + + if own_declarations: + cls.dtype = _build_struct_dtype(cls, own_declarations, length) + # Only an AnonymousPayload root (its lone __value__ field) unwraps on + # parse; a StructPayload always returns the wrapper, never auto-unwraps. + if getattr(cls, "_root", False): + cls._single_member = own_declarations[0][0] + + if "_repr_fields" not in cls.__dict__: + cls._repr_fields = cls._collect_repr_fields() + + cls._scalar_cls = cls + cls._batch_cls = cls # rebound below once Batch is generated + + if hasattr(cls, "dtype"): + batch_attrs: dict[str, Any] = {"__init__": _batch_init_disabled} + for name, val in cls.__dict__.items(): + if isinstance(val, _SCALAR_DECLARATION_TYPES): + batch_attrs[name] = val._to_batch() + cls.Batch = type( + f"{cls.__name__}Batch", + (cls,), + batch_attrs, + _batch_of=cls, + ) + + cls._defaults = cls._collect_defaults() + + @classmethod + def from_array(cls, arr: "np.ndarray") -> Self: + target = cls._scalar_cls if arr.ndim == 0 else cls._batch_cls + obj = target.__new__(target) + obj._arr = arr + return obj # type: ignore[return-value] + + @classmethod + def from_buffer(cls, buf: bytes | bytearray | memoryview) -> Self: + arr = np.frombuffer(buf, dtype=cls.dtype) + return cls.from_array(arr[0] if len(arr) == 1 else arr) + + @property + def raw_payload(self) -> NDArray[NpStructT]: + return self._arr + + def to_columns( + self, *, decode_enums: bool = True, demux_bit_masks: bool = False + ) -> list[Column]: + """Returns a list of Column where each member represents a field from a payload across multiple messages. + + ``decode_enums`` controls whether ``GroupMask`` (enum) columns become + category codes + labels (True) or raw integer codes (False) — a + shape-preserving relabel. ``demux_bit_masks`` controls whether a + ``BitMask`` (flag) column is expanded into one boolean column per flag + member (True) or kept as a single raw-integer column (False) — a shape + change. The two are orthogonal and apply to different descriptor kinds. + """ + arr = np.atleast_1d(self._arr) + # Each descriptor renders its own column(s); resolve via the scalar twin. + scalar_cls = type(self)._scalar_cls + cols: list[Column] = [] + for f in self._repr_fields: + desc = scalar_cls._mro_descriptor(f) + assert desc is not None + cols.extend( + desc._columns(arr, f, decode_enums=decode_enums, demux_bit_masks=demux_bit_masks) + ) + return cols + + def __len__(self) -> int: + return 1 if self._arr.ndim == 0 else len(self._arr) + + def _repr_kwargs(self) -> str: + return ", ".join(f"{f}={getattr(self, f)!r}" for f in self._repr_fields) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self._repr_kwargs()})" + + def __str__(self) -> str: + return repr(self) + + @classmethod + def unwrap(cls, arr: "np.ndarray") -> Any: + """Dispatch hook used by ``RegisterBase.parse``. + + Struct payloads always return a typed wrapper so descriptors like + ``payload.Channel0`` work. Anonymous payloads override this to return the + raw numpy scalar/ndarray directly, or — for an ``AnonymousPayload`` root — + the unwrapped ``__value__`` (the single-member branch below, reached via + the override's ``super()`` call). A struct payload never auto-unwraps. + """ + obj = cls.from_array(arr) + if cls._single_member is not None and arr.ndim == 0: + return getattr(obj, cls._single_member) + return obj + + +# --------------------------------------------------------------------------- +# StructPayload — base for named-field (struct) register payloads +# --------------------------------------------------------------------------- + + +@dataclass_transform( + kw_only_default=True, + field_specifiers=(Field, GroupMask, BitMask), +) +class StructPayload(PayloadBase[NpStructT]): + """Base class for struct register payloads with typed field descriptors. + + Subclasses declare fields using ``Field``, ``GroupMask``, or ``BitMask`` + descriptors. Type checkers synthesize a keyword-only ``__init__`` from + those declarations, so constructor calls are fully type-checked and have + IDE autocompletion. + + The type argument (``StructPayload[np.uint8]``) is the base element type; it + sets the unit for ``offset=`` and the integer width of masked reads. The + optional ``length=`` class kwarg fixes the payload size in base elements + (the register ``length``); when omitted it defaults to the max member extent. + + Example:: + + class MyPayload(StructPayload[np.uint8]): + channel: np.uint16 = Field(UInt16Converter(), offset=0) + flags: MyFlags = BitMask(enum=MyFlags, offset=2) + """ + + +# --------------------------------------------------------------------------- +# AnonymousPayload — single unnamed slot, no user-facing descriptors +# --------------------------------------------------------------------------- + + +class AnonymousPayload(PayloadBase[NpStructT]): + """Payload backed by a single unnamed numpy dtype (scalar or sub-array). + + Subclasses declare the dtype via class kwargs, not descriptors: + + class PayloadU16(AnonymousPayload, scalar_dtype=" None: + # A class-body descriptor field selects root mode; it must be named __value__. + body_fields = [ + n for n, v in cls.__dict__.items() if isinstance(v, _SCALAR_DECLARATION_TYPES) + ] + if body_fields: + if scalar_dtype is not None: + raise TypeError( + f"{cls.__name__}: __value__ is mutually exclusive with scalar_dtype=" + ) + if body_fields != [cls._VALUE_FIELD]: + raise TypeError( + f"{cls.__name__}: a single-value (root) payload declares exactly one field " + f"named {cls._VALUE_FIELD!r}; found {body_fields}. Use StructPayload for " + f"multi-field payloads." + ) + cls._root = True + super().__init_subclass__(**kwargs) # pyright: ignore[reportArgumentType] + return + # Raw scalar slot required, unless a Batch twin / array concrete supplies dtype. + if scalar_dtype is None and "_batch_of" not in kwargs and "dtype" not in cls.__dict__: + raise TypeError( + f"{cls.__name__}: an AnonymousPayload subclass must define its single slot via a " + f"{cls._VALUE_FIELD!r} descriptor field or scalar_dtype= (a codec is a " + f"{cls._VALUE_FIELD!r} Field with a Converter)." + ) + if scalar_dtype is not None: + cls.dtype = np.dtype(scalar_dtype) + cls._repr_fields = () + super().__init_subclass__(**kwargs) # pyright: ignore[reportArgumentType] + + def __init__(self, value: object = _MISSING_INIT, /, **kwargs: object) -> None: # type: ignore[override] + if type(self)._root: + # root mode: PayloadBase encodes the value into the single __value__ field + if value is not _MISSING_INIT: + super().__init__(value) + else: + super().__init__(**kwargs) + return + if value is _MISSING_INIT: + if "value" in kwargs: + value = kwargs.pop("value") + else: + raise TypeError(f"{type(self).__name__}() requires a value") + if kwargs: + raise TypeError(f"{type(self).__name__}() got unexpected kwargs: {sorted(kwargs)}") + self._arr = np.asarray(value, dtype=self.dtype) + + @classmethod + def unwrap(cls, arr: "np.ndarray") -> Any: + if cls._root: + return super().unwrap(arr) # PayloadBase single-member unwrap (.__value__) + # 0-D → numpy scalar via item-like access (preserves dtype). + # 1-D / sub-array → return the ndarray as-is. + return arr if arr.ndim > 0 else arr[()] + + def _repr_kwargs(self) -> str: + if type(self)._root: + return super()._repr_kwargs() + return repr(self._arr.tolist() if self._arr.ndim > 0 else self._arr[()]) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self._repr_kwargs()})" + + def to_columns( + self, *, decode_enums: bool = True, demux_bit_masks: bool = False + ) -> list[Column]: + # Anonymous values carry no name (name=None); the consumer supplies the label. + if type(self)._root: + arr = np.atleast_1d(self._arr) + root = type(self)._scalar_cls._mro_descriptor(self._VALUE_FIELD) + assert root is not None + return root._columns( + arr, None, decode_enums=decode_enums, demux_bit_masks=demux_bit_masks + ) + arr = np.atleast_1d(self._arr) + # Sub-array dtype (array register): one column per element, positionally named. + if arr.ndim > 1: + return [Column(str(i), arr[:, i]) for i in range(arr.shape[1])] + return [Column(None, arr)] + + +@final +class PayloadU8(AnonymousPayload[np.uint8], scalar_dtype=np.dtype("u1")): + pass + + +@final +class PayloadU16(AnonymousPayload[np.uint16], scalar_dtype=np.dtype(" T: + """Decode a 0-D structured-array element into a Python value.""" + + @abstractmethod + def decode_batch(self, view: "NDArray[np.generic]") -> Any: + """Decode a 1-D structured-array column into an array-like.""" + + @abstractmethod + def encode_into(self, view: NDArray[np.generic], value: T) -> None: + """Write a Python value back into a structured-array element.""" + + +# --------------------------------------------------------------------------- +# Built-in converters +# --------------------------------------------------------------------------- + + +class IdentityConverter(Converter[NpScalarT]): + """Pass-through converter — the raw numpy scalar is returned as-is.""" + + def __init__(self, dtype: "np.dtype[NpScalarT] | str | type[NpScalarT]") -> None: + self.dtype = np.dtype(dtype) + + def decode_scalar(self, view: np.generic) -> NpScalarT: + return cast( + NpScalarT, view + ) # this should be safe since dtype is scalar and matches the type var + + def decode_batch(self, view: NDArray[np.generic]) -> "NDArray[NpScalarT]": + return cast("NDArray[NpScalarT]", view) + + def encode_into(self, view: NDArray[np.generic], value: NpScalarT) -> None: + view[...] = value + + +class UInt8Converter(IdentityConverter[np.uint8]): + """A built-in UInt8 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.uint8) + + +class SInt8Converter(IdentityConverter[np.int8]): + """A built-in Int8 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.int8) + + +class UInt16Converter(IdentityConverter[np.uint16]): + """A built-in UInt16 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.uint16) + + +class Int16Converter(IdentityConverter[np.int16]): + """A built-in Int16 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.int16) + + +class UInt32Converter(IdentityConverter[np.uint32]): + """A built-in UInt32 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.uint32) + + +class Int32Converter(IdentityConverter[np.int32]): + """A built-in Int32 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.int32) + + +class UInt64Converter(IdentityConverter[np.uint64]): + """A built-in UInt64 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.uint64) + + +class Int64Converter(IdentityConverter[np.int64]): + """A built-in Int64 passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.int64) + + +class FloatConverter(IdentityConverter[np.float32]): + """A built-in float passthrough converter""" + + def __init__(self) -> None: + super().__init__(np.float32) + + +class BoolConverter(Converter[bool]): + """Whole-element ``interfaceType: bool`` (or a single masked bit via ``Field(BoolConverter(), mask=...)``). + + The element is non-zero → ``True``. Operates on a single base element. + """ + + def __init__(self, dtype: "np.dtype | str | type" = np.uint8) -> None: + self.dtype = np.dtype(dtype) + + def decode_scalar(self, view: np.generic) -> bool: + return bool(view) + + def decode_batch(self, view: NDArray[np.generic]) -> Any: + return np.asarray(view) != 0 + + def encode_into(self, view: NDArray[np.generic], value: bool) -> None: + view[...] = 1 if value else 0 + + +class EnumConverter(Converter[E]): + """Whole-element ``interfaceType: `` enum (strict). + + Maps a base element to an ``enum.IntEnum`` member; an unknown code raises + ``ValueError`` (matching Python ``IntEnum`` semantics). For masked enum + sub-fields use :class:`~harp.protocol.GroupMask` with ``enum=`` instead. + """ + + def __init__(self, enum_cls: "type[E]", dtype: "np.dtype | str | type" = np.uint8) -> None: + self._enum = enum_cls + self.dtype = np.dtype(dtype) + + def decode_scalar(self, view: np.generic) -> E: + return self._enum(int(view)) + + def decode_batch(self, view: NDArray[np.generic]) -> Any: + return np.asarray(view) + + def encode_into(self, view: NDArray[np.generic], value: E) -> None: + view[...] = int(value) + + +class StringConverter(Converter[str]): + """Converts a fixed-length byte array to/from a Python ``str``.""" + + def __init__(self, length: int, encoding: str = "ascii") -> None: + self._length = length + self._encoding = encoding + self.dtype = np.dtype((np.uint8, (length,))) + + def decode_scalar(self, view: np.generic) -> str: + return bytes(view).rstrip(b"\x00").decode(self._encoding) + + _VIEW_CASTABLE_ENCODINGS = frozenset({"ascii", "latin1", "latin-1", "iso-8859-1"}) + + def decode_batch(self, view: NDArray[np.generic]) -> Any: + if self._encoding not in self._VIEW_CASTABLE_ENCODINGS: + return np.array( + [bytes(row).rstrip(b"\x00").decode(self._encoding) for row in view], + dtype=object, + ) + return view.reshape(-1, self._length).view(f"S{self._length}").reshape(-1).astype(str) + + def encode_into(self, view: NDArray[np.generic], value: str) -> None: + encoded = value.encode(self._encoding)[: self._length] + padded = encoded.ljust(self._length, b"\x00") + view[...] = np.frombuffer(padded, dtype=np.uint8) + + +@dataclass(frozen=True) +class HarpVersion: + """Represents a Harp version""" + + major: int + minor: int + patch: int + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + +class HarpVersionConverter(Converter[HarpVersion]): + """A built-in converter for a HarpVersion object.""" + + def __init__(self, component: "np.dtype | str | type" = np.uint8) -> None: + self.dtype = np.dtype((component, (3,))) + + def decode_scalar(self, view: np.generic) -> HarpVersion: + c = np.asarray(view).tolist() + return HarpVersion(int(c[0]), int(c[1]), int(c[2])) + + def decode_batch(self, view: NDArray[np.generic]) -> Any: + v = np.atleast_2d(view) + return np.frompyfunc(HarpVersion, 3, 1)(v[:, 0], v[:, 1], v[:, 2]) + + def encode_into(self, view: NDArray[np.generic], value: HarpVersion) -> None: + view[...] = np.array([value.major, value.minor, value.patch], dtype=self.dtype.base) diff --git a/src/packages/harp-protocol/src/harp/protocol/_payload_type.py b/src/packages/harp-protocol/src/harp/protocol/_payload_type.py new file mode 100644 index 0000000..c9ee0c0 --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/_payload_type.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from enum import Enum + +import numpy as np + +from ._constants import _TIMESTAMP_FLAG + + +class PayloadType(Enum): + """Harp scalar payload types. Each value is the corresponding numpy dtype.""" + + U8 = np.dtype("u1") + U16 = np.dtype(" np.dtype: + """Returns the corresponding numpy dtype""" + return self.value + + +@dataclass(frozen=True) +class PayloadTypeInfo: + has_timestamp: bool + payload_type: PayloadType + element_size: int # bytes per element + + +_SIZE_TO_UNSIGNED = { + 1: PayloadType.U8, + 2: PayloadType.U16, + 4: PayloadType.U32, + 8: PayloadType.U64, +} +_SIZE_TO_SIGNED = { + 1: PayloadType.S8, + 2: PayloadType.S16, + 4: PayloadType.S32, + 8: PayloadType.S64, +} + + +def decode_payload_type(b: int) -> PayloadTypeInfo: + """Decode a PayloadType byte. Raises ``ValueError`` for invalid bytes.""" + size = b & 0x0F + if size not in (1, 2, 4, 8): + raise ValueError(f"Invalid size nibble {size} in PayloadType byte: 0x{b:02x}") + if b & 0x20: + raise ValueError(f"Reserved bit 5 set in PayloadType byte: 0x{b:02x}") + + has_timestamp = bool(b & _TIMESTAMP_FLAG) + is_float = bool(b & 0x40) + is_signed = bool(b & 0x80) + + if is_float and is_signed: + raise ValueError(f"IsFloat and IsSigned cannot both be set: 0x{b:02x}") + if is_float and (b & 0xEF) != 0x44: + raise ValueError(f"Float payload must have size=4 only: 0x{b:02x}") + + if is_float: + payload_type = PayloadType.Float + elif is_signed: + payload_type = _SIZE_TO_SIGNED[size] + else: + payload_type = _SIZE_TO_UNSIGNED[size] + + return PayloadTypeInfo( + has_timestamp=has_timestamp, payload_type=payload_type, element_size=size + ) + + +def encode_payload_type(payload_type: PayloadType, *, has_timestamp: bool = False) -> int: + """Encode a PayloadType back to a protocol byte.""" + dtype = payload_type.numpy_dtype + kind = dtype.kind # 'u', 'i', 'f' + size = dtype.itemsize + + b = size + if kind == "i": + b |= 0x80 + elif kind == "f": + b |= 0x40 + if has_timestamp: + b |= _TIMESTAMP_FLAG + return int(b) diff --git a/src/packages/harp-protocol/src/harp/protocol/_register.py b/src/packages/harp-protocol/src/harp/protocol/_register.py new file mode 100644 index 0000000..9f221dc --- /dev/null +++ b/src/packages/harp-protocol/src/harp/protocol/_register.py @@ -0,0 +1,391 @@ +from abc import ABC, ABCMeta +from typing import Any, ClassVar, Generic, TypeVar, cast, final, overload + +import numpy as np +from numpy.typing import NDArray +from typing_extensions import Sentinel + +from ._builder import build_message_frame +from ._constants import ( + _DEFAULT_PORT, + _HEADER_LEN, + _TICK_PERIOD_S, + _TIMESTAMP_FLAG, + _TIMESTAMPED_PAYLOAD_OFFSET, + _TS_MICROS_OFFSET, +) +from ._message import HarpMessage +from ._message_type import MessageType +from ._payload import ( + Batch, + PayloadBase, + PayloadFloat, + PayloadFloatArray, + PayloadS8, + PayloadS8Array, + PayloadS16, + PayloadS16Array, + PayloadS32, + PayloadS32Array, + PayloadS64, + PayloadS64Array, + PayloadU8, + PayloadU8Array, + PayloadU16, + PayloadU16Array, + PayloadU32, + PayloadU32Array, + PayloadU64, + PayloadU64Array, +) +from ._payload_type import PayloadType + +_MISSING = Sentinel("_MISSING") + +U = TypeVar("U") +_R = TypeVar("_R") +_AR = TypeVar("_AR", bound="RegisterBase[Any]") + + +class _LazyTimestamps: + """Seconds + microseconds timestamp views, combined into float64 on first use. + + Combining the raw views costs an O(n) pass over every frame (two ``astype`` + casts plus a multiply-add), independent of the register's payload — so eagerly + computing it in ``parse_bulk`` taxes every call even when the caller never + reads the timestamps. Deferring the combine until the array is actually + accessed (and caching the result) avoids that cost in the common case where + only the payload is needed. + """ + + __slots__ = ("_ts_s", "_ts_us", "_values") + + def __init__(self, ts_s: np.ndarray, ts_us: np.ndarray) -> None: + self._ts_s = ts_s + self._ts_us = ts_us + self._values: np.ndarray | None = None + + def _resolve(self) -> np.ndarray: + if self._values is None: + out = np.multiply(self._ts_us, _TICK_PERIOD_S, dtype=np.float64) + np.add(self._ts_s, out, out=out) + self._values = out + return self._values + + def __array__(self, dtype: "np.dtype | None" = None) -> np.ndarray: + arr = self._resolve() + return arr if dtype is None else arr.astype(dtype) + + def __len__(self) -> int: + return len(self._ts_s) + + def __iter__(self): + return iter(self._resolve()) + + def __getitem__(self, item: Any) -> Any: + return self._resolve()[item] + + def __repr__(self) -> str: + return repr(self._resolve()) + + +class _RegisterMeta(ABCMeta): + """Calling a register class with an address creates a one-off subclass: ``RegisterU32(0x08)``.""" + + def __call__(cls: "type[_R]", address: int) -> "type[_R]": + return cast( + "type[_R]", + type(f"{cls.__name__}_{address:#04x}", (cls,), {"address": address}), + ) + + +class RegisterBase(ABC, Generic[U]): + """Abstract base for all typed Harp registers. + + The generic parameter ``U`` is the static return type of :meth:`parse` — the + user-facing value, *not* necessarily ``payload_class`` (that is the wire + encoding). The two coincide only for multi-member struct payloads: + + * scalar registers → a numpy scalar (e.g. ``np.uint16``); + * array registers → ``NDArray[…]`` of fixed length; + * multi-member struct registers → the payload class itself; + * single-member registers that unwrap on parse → the inner value type, e.g. + ``RegisterBase[str]`` (DeviceName), ``RegisterBase[HarpVersion]``, or + ``RegisterBase[ClockConfigurationFlags]`` for a whole-register ``BitMask`` + / ``GroupMask`` — even though each still has a ``payload_class``. + + Subclasses must define ``address``, ``payload_type``, and + ``payload_class`` as ``ClassVar``s. + """ + + address: ClassVar[int] + payload_type: ClassVar[PayloadType] + payload_class: ClassVar[type[PayloadBase[Any]]] + length: ClassVar[int | None] = None + + @classmethod + def parse(cls, value: HarpMessage | bytes | bytearray | memoryview) -> U: + """Parse a single message into the user-facing payload value. + + Struct payloads return a typed wrapper (descriptor access like + ``payload.Channel0`` works). Anonymous payloads (scalar / array + registers) return the raw numpy scalar or ndarray directly. + """ + buf = value.payload if isinstance(value, HarpMessage) else value + record = np.frombuffer(buf, dtype=cls.payload_class.dtype, count=1)[0] + return cast(U, cls.payload_class.unwrap(record)) + + @classmethod + def parse_bulk( + cls, + source: bytes | bytearray | memoryview, + *, + parse_timestamp: bool = True, + ) -> "tuple[np.ndarray, _LazyTimestamps | None, np.ndarray | None, Batch[Any]]": + """Parse a bulk buffer containing one or more frames of this register type. Returns (data, timestamps, msgtype_view, payload).""" + # Returns (data, timestamps, msgtype_view, payload). ``data`` is + payload_cls = cls.payload_class + data = np.frombuffer(source, dtype=np.uint8) + + if len(data) == 0: + # No frames, but still need to return a Batch with the right dtype. + payload = payload_cls.from_array(np.empty(0, dtype=payload_cls.dtype)) + return data, None, None, cast("Batch[Any]", payload) + + stride = ( + int(data[1]) + 2 + ) # TODO this assumes all frames have the same length but we may want to revisit in the future. + nrows = len(data) // stride + is_timestamped = bool(int(data[4]) & _TIMESTAMP_FLAG) + payload_offset = _TIMESTAMPED_PAYLOAD_OFFSET if is_timestamped else _HEADER_LEN + + if is_timestamped and parse_timestamp: + ts_s = np.ndarray(nrows, dtype=" bytes: ... + + @overload + @classmethod + def format( + cls, + value: U, + *, + message_type: MessageType = MessageType.Write, + timestamp: float | None = None, + port: int = _DEFAULT_PORT, + ) -> bytes: ... + # We go with "U" for typing but it is worth noting that we accept "Any" below. + # However for API ergonomics we want to keep the type hint for symmetry + @final + @classmethod + def format( + cls, + value: Any = _MISSING, + *, + message_type: MessageType | None = None, + timestamp: float | None = None, + port: int = _DEFAULT_PORT, + ) -> bytes: + """Build a Harp frame for this register. No value → Read; with value → Write.""" + if value is _MISSING: + mt = MessageType.Read if message_type is None else message_type + return build_message_frame( + mt, cls.address, cls.payload_type, port=port, timestamp=timestamp + ) + else: + mt = MessageType.Write if message_type is None else message_type + if isinstance(value, PayloadBase): + raw = value.raw_payload.tobytes() + elif isinstance(value, np.ndarray): + raw = value.tobytes() + else: + # A bare high-level value (the symmetric counterpart of what + # parse() returns): let the payload class encode it, so any + # converter (e.g. a str via StringConverter) is applied. + raw = cls.payload_class(value).raw_payload.tobytes() + return build_message_frame( + mt, cls.address, cls.payload_type, raw, port=port, timestamp=timestamp + ) + + +class RegisterU8(RegisterBase[np.uint8], metaclass=_RegisterMeta): + """A simple scalar register with a uint8 payload. ``parse()`` returns ``np.uint8``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = PayloadU8 + + +class RegisterU16(RegisterBase[np.uint16], metaclass=_RegisterMeta): + """A simple scalar register with a uint16 payload. ``parse()`` returns ``np.uint16``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U16 + payload_class = PayloadU16 + + +class RegisterU32(RegisterBase[np.uint32], metaclass=_RegisterMeta): + """A simple scalar register with a uint32 payload. ``parse()`` returns ``np.uint32``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U32 + payload_class = PayloadU32 + + +class RegisterU64(RegisterBase[np.uint64], metaclass=_RegisterMeta): + """A simple scalar register with a uint64 payload. ``parse()`` returns ``np.uint64``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U64 + payload_class = PayloadU64 + + +class RegisterS8(RegisterBase[np.int8], metaclass=_RegisterMeta): + """A simple scalar register with a int8 payload. ``parse()`` returns ``np.int8``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S8 + payload_class = PayloadS8 + + +class RegisterS16(RegisterBase[np.int16], metaclass=_RegisterMeta): + """A simple scalar register with a int16 payload. ``parse()`` returns ``np.int16``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S16 + payload_class = PayloadS16 + + +class RegisterS32(RegisterBase[np.int32], metaclass=_RegisterMeta): + """A simple scalar register with a int32 payload. ``parse()`` returns ``np.int32``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S32 + payload_class = PayloadS32 + + +class RegisterS64(RegisterBase[np.int64], metaclass=_RegisterMeta): + """A simple scalar register with a int64 payload. ``parse()`` returns ``np.int64``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S64 + payload_class = PayloadS64 + + +class RegisterFloat(RegisterBase[np.float32], metaclass=_RegisterMeta): + """A simple scalar register with a float32 payload. ``parse()`` returns ``np.float32``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.Float + payload_class = PayloadFloat + + +class _ArrayRegisterMeta(ABCMeta): + """A base metaclass for array registers. Calling with address and length creates a concrete subclass: ``RegisterU16Array(0x28, length=3)``.""" + + def __call__(cls: "type[_AR]", address: int, *, length: int) -> "type[_AR]": # type: ignore[override, misc] + base_payload = cls.payload_class # type: ignore[attr-defined] + # Anonymous payloads carry a plain (non-structured) dtype. The array + # variant uses a sub-dtype (inner_dtype, (length,)) so a single buffer + # element decodes directly to an ndarray of shape (length,). + inner = base_payload.dtype + sub_dtype = np.dtype((inner, (length,))) + concrete_payload = type( + f"{base_payload.__name__}_{length}", + (base_payload,), + {"dtype": sub_dtype}, + ) + return cast( + "type[_AR]", + type( + f"{cls.__name__}_{address:#04x}", + (cls,), + { + "address": address, + "length": length, + "payload_class": concrete_payload, + }, + ), + ) + + +class RegisterU8Array(RegisterBase[NDArray[np.uint8]], metaclass=_ArrayRegisterMeta): + """A simple array register with a uint8 array payload. It must be instantiated with a length: ``RegisterU8Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.uint8]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = PayloadU8Array + + +class RegisterU16Array(RegisterBase[NDArray[np.uint16]], metaclass=_ArrayRegisterMeta): + """A simple array register with a uint16 array payload. It must be instantiated with a length: ``RegisterU16Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.uint16]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U16 + payload_class = PayloadU16Array + + +class RegisterU32Array(RegisterBase[NDArray[np.uint32]], metaclass=_ArrayRegisterMeta): + """A simple array register with a uint32 array payload. It must be instantiated with a length: ``RegisterU32Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.uint32]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U32 + payload_class = PayloadU32Array + + +class RegisterU64Array(RegisterBase[NDArray[np.uint64]], metaclass=_ArrayRegisterMeta): + """A simple array register with a uint64 array payload. It must be instantiated with a length: ``RegisterU64Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.uint64]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.U64 + payload_class = PayloadU64Array + + +class RegisterS8Array(RegisterBase[NDArray[np.int8]], metaclass=_ArrayRegisterMeta): + """A simple array register with a int8 array payload. It must be instantiated with a length: ``RegisterS8Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.int8]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S8 + payload_class = PayloadS8Array + + +class RegisterS16Array(RegisterBase[NDArray[np.int16]], metaclass=_ArrayRegisterMeta): + """A simple array register with a int16 array payload. It must be instantiated with a length: ``RegisterS16Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.int16]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S16 + payload_class = PayloadS16Array + + +class RegisterS32Array(RegisterBase[NDArray[np.int32]], metaclass=_ArrayRegisterMeta): + """A simple array register with a int32 array payload. It must be instantiated with a length: ``RegisterS32Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.int32]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S32 + payload_class = PayloadS32Array + + +class RegisterS64Array(RegisterBase[NDArray[np.int64]], metaclass=_ArrayRegisterMeta): + """A simple array register with a int64 array payload. It must be instantiated with a length: ``RegisterS64Array(0x28, length=3)``. ``parse()`` returns ``NDArray[np.int64]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.S64 + payload_class = PayloadS64Array + + +class RegisterFloatArray(RegisterBase[NDArray[np.float32]], metaclass=_ArrayRegisterMeta): + """A simple array register with a float32 array payload. It must be instantiated with a length: ``RegisterFloatArray(0x28, length=3)``. ``parse()`` returns ``NDArray[np.float32]``.""" + + payload_type: ClassVar[PayloadType] = PayloadType.Float + payload_class = PayloadFloatArray diff --git a/src/packages/harp-protocol/src/harp/protocol/py.typed b/src/packages/harp-protocol/src/harp/protocol/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/packages/harp-serial/README.md b/src/packages/harp-serial/README.md new file mode 100644 index 0000000..50e74d2 --- /dev/null +++ b/src/packages/harp-serial/README.md @@ -0,0 +1,21 @@ +# harp-serial + +Serial transport for [`harp-device`](../harp-device). Provides `SerialTransport` +and the `open_serial_device` factory, which pairs a `Device` class with a serial +port. This is the package that pulls in `pyserial`. + +## Usage + +Like the builtin `open`, the returned device is connected and ready; use it in a +`with` block for guaranteed cleanup: + +```python +from harp.device import Device, WhoAmI +from harp.serial import open_serial_device + +with open_serial_device(Device, port="COM3", baudrate=1_000_000) as dev: + print(dev.read(WhoAmI).parsed) +``` + +Pass any `Device` subclass (e.g. a generated device class) instead of the base +`Device` to talk to a specific device. diff --git a/src/packages/harp-serial/pyproject.toml b/src/packages/harp-serial/pyproject.toml new file mode 100644 index 0000000..9799e3d --- /dev/null +++ b/src/packages/harp-serial/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "harp-serial" +dynamic = ["version"] +description = "Serial transport for Harp devices" +requires-python = ">=3.11" +dependencies = [ + "harp-protocol", + "harp-device", + "pyserial>=3.5", +] + +[build-system] +requires = ["setuptools>=77", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +root = "../../.." + +[tool.setuptools.packages.find] +where = ["src"] +include = ["harp*"] diff --git a/src/packages/harp-serial/src/harp/serial/__init__.py b/src/packages/harp-serial/src/harp/serial/__init__.py new file mode 100644 index 0000000..20877d0 --- /dev/null +++ b/src/packages/harp-serial/src/harp/serial/__init__.py @@ -0,0 +1,6 @@ +from ._serial import SerialTransport, open_serial_device + +__all__ = [ + "SerialTransport", + "open_serial_device", +] diff --git a/src/packages/harp-serial/src/harp/serial/_serial.py b/src/packages/harp-serial/src/harp/serial/_serial.py new file mode 100644 index 0000000..45d5b9a --- /dev/null +++ b/src/packages/harp-serial/src/harp/serial/_serial.py @@ -0,0 +1,70 @@ +"""Serial transport and factory for Harp devices.""" + +from typing import TypeVar + +import serial + +from harp.device import Device, TransportError + +D = TypeVar("D", bound=Device) + +DEFAULT_BAUDRATE: int = 1_000_000 + + +class SerialTransport: + """A serial-port :class:`~harp.device.ITransport` (structural conformance).""" + + def __init__(self, port: str, baudrate: int = DEFAULT_BAUDRATE) -> None: + self._port = port + self._baudrate = baudrate + self._serial: serial.Serial | None = None + + def open(self) -> None: + try: + self._serial = serial.Serial(self._port, self._baudrate, timeout=0.1) + self._serial.dtr = True + except serial.SerialException as exc: + raise TransportError(f"Failed to open serial port {self._port!r}: {exc}") from exc + + def write(self, data: bytes) -> None: + assert self._serial is not None + try: + self._serial.write(data) + except serial.SerialException as exc: + raise TransportError(str(exc)) from exc + + def read(self) -> bytes: + assert self._serial is not None + try: + waiting = self._serial.in_waiting + return self._serial.read(waiting if waiting > 0 else 1) + except serial.SerialException as exc: + raise TransportError(str(exc)) from exc + + def close(self) -> None: + if self._serial is None: + return + try: + self._serial.dtr = False + except Exception: + pass + self._serial.close() + + +def open_serial_device( + device: type[D], + *, + port: str, + baudrate: int = DEFAULT_BAUDRATE, + raise_on_error: bool = True, +) -> D: + """Build ``device`` over a serial transport and open it. + + Like the builtin :func:`open`, the returned device is already connected; + use it directly or in a ``with`` block for guaranteed close:: + + with open_serial_device(behavior.Device, port="COM3") as dev: + dev.read(behavior.WhoAmI) + """ + transport = SerialTransport(port, baudrate) + return device(transport, raise_on_error=raise_on_error).open() diff --git a/src/packages/harp-serial/src/harp/serial/py.typed b/src/packages/harp-serial/src/harp/serial/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..99c698b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""Pytest configuration — fixtures and path setup.""" + +from tests.fixtures import ( # re-export so conftest-aware code still works + TIMESTAMP_1S, + make_frame_from_raw, +) + +__all__ = ["make_frame_from_raw", "TIMESTAMP_1S"] diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..764c1b1 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,19 @@ +TIMESTAMP_1S: bytes = b"\x01\x00\x00\x00\x00\x00" + + +def make_frame_from_raw( + msg_type_byte: int, + address: int, + port: int, + payload_type: int, + payload: bytes, + *, + timestamp: bytes | None = None, +) -> bytes: + ts = timestamp if timestamp is not None else b"" + pt_byte = payload_type | (0x10 if ts else 0) + body = bytes([address, port, pt_byte]) + ts + payload + length = len(body) + 1 # +1 for checksum + frame = bytes([msg_type_byte, length]) + body + checksum = sum(frame) & 0xFF + return frame + bytes([checksum]) diff --git a/tests/protocol/__init__.py b/tests/protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/protocol/test_checksum.py b/tests/protocol/test_checksum.py new file mode 100644 index 0000000..4230c1a --- /dev/null +++ b/tests/protocol/test_checksum.py @@ -0,0 +1,33 @@ +import pytest +from harp.protocol._checksum import compute, validate + + +@pytest.mark.parametrize( + "data, expected", + [ + ( + bytes([0x01, 0x04, 0x00, 0xFF, 0x01, 0x00]), + (0x01 + 0x04 + 0x00 + 0xFF + 0x01) & 0xFF, + ), + (bytes([0xFF] * 6), (0xFF * 5) & 0xFF), # wraps + (b"\xff", 0), # single byte: sums nothing + ], +) +def test_compute(data, expected): + assert compute(data) == expected + + +@pytest.mark.parametrize( + "frame, expected", + [ + (None, True), # None = auto-build a valid frame + (bytes([0x02, 0x05, 0x00, 0xFF, 0x01, 0xAB, 0x00]), False), # wrong checksum + (b"\xff", False), # too short + (b"", False), # empty + ], +) +def test_validate(frame, expected): + if frame is None: + body = bytes([0x02, 0x05, 0x00, 0xFF, 0x01]) + frame = body + bytes([sum(body) & 0xFF]) + assert validate(frame) == expected diff --git a/tests/protocol/test_converter.py b/tests/protocol/test_converter.py new file mode 100644 index 0000000..901095d --- /dev/null +++ b/tests/protocol/test_converter.py @@ -0,0 +1,223 @@ +"""Tests for the unified _Field + _Converter machinery in _payload.py. + +Covers: +* IdentityConverter via auto-generated _Field (parity with previous _Field). +* StringConverter (sub-array uint8 ↔ str). +* EnumConverter (full-byte enum decoding). +* Declarations-build-dtype direction (no _dtype on the subclass). +* Reserved-name collision check. +""" + +import enum + +import numpy as np +import pytest +from harp.data import payload_to_dataframe +from harp.protocol._payload import ( + PayloadBase, + BitMask, + Field, + GroupMask, + _IdentityConverter, +) +from harp.protocol._payload_converters import StringConverter as _StringConverter + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _Color(enum.IntEnum): + Red = 0 + Green = 1 + Blue = 2 + + +class _Flag(enum.IntFlag): + A = 0x01 + + +# --------------------------------------------------------------------------- +# IdentityConverter — pass-through field +# --------------------------------------------------------------------------- + + +class _NumericPayload(PayloadBase): + a = Field(converter=_IdentityConverter(" list[Column]: + return [ + Column("flag_a", (self.raw_payload["packed"] & 0x01).astype(bool)), + Column("flag_b", ((self.raw_payload["packed"] >> 1) & 0x01).astype(bool)), + ] + + +def _make_simple_bytes(n: int) -> bytes: + arr = np.zeros(n, dtype=SimplePayload.dtype) + arr["x"] = np.arange(n, dtype=np.int16) * -1 + arr["y"] = np.arange(n, dtype=np.uint8) + return arr.tobytes() + + +def test_from_buffer_shape(): + data = _make_simple_bytes(5) + p = SimplePayload.from_buffer(data) + assert len(p) == 5 + + +def test_from_buffer_values(): + data = _make_simple_bytes(3) + p = SimplePayload.from_buffer(data) + np.testing.assert_array_equal(p.x, [0, -1, -2]) + np.testing.assert_array_equal(p.y, [0, 1, 2]) + + +def test_to_dataframe_columns(): + p = SimplePayload.from_buffer(_make_simple_bytes(3)) + df = payload_to_dataframe(p) + assert list(df.columns) == ["x", "y"] + assert len(df) == 3 + + +def test_to_dataframe_override(): + arr = np.array([(0b00000011,), (0b00000001,), (0b00000010,)], dtype=BitPackedPayload.dtype) + p = BitPackedPayload.from_buffer(arr.tobytes()) + df = payload_to_dataframe(p) + assert list(df.columns) == ["flag_a", "flag_b"] + assert list(df["flag_a"]) == [True, True, False] + assert list(df["flag_b"]) == [True, False, True] + + +def test_from_buffer_zero_copy(): + data = _make_simple_bytes(4) + p = SimplePayload.from_buffer(data) + # np.frombuffer returns a read-only view — writes should raise + with pytest.raises((ValueError, TypeError)): + p.raw_payload["x"][0] = 999 + + +def test_payload_property(): + p = SimplePayload.from_buffer(_make_simple_bytes(2)) + assert p.raw_payload.dtype == SimplePayload.dtype diff --git a/tests/protocol/test_payload_type.py b/tests/protocol/test_payload_type.py new file mode 100644 index 0000000..963ed6e --- /dev/null +++ b/tests/protocol/test_payload_type.py @@ -0,0 +1,63 @@ +import pytest +from harp.protocol._payload_type import ( + PayloadType, + decode_payload_type, + encode_payload_type, +) + + +@pytest.mark.parametrize( + "byte, expected_type, has_ts", + [ + (0x01, PayloadType.U8, False), + (0x02, PayloadType.U16, False), + (0x04, PayloadType.U32, False), + (0x08, PayloadType.U64, False), + (0x81, PayloadType.S8, False), + (0x82, PayloadType.S16, False), + (0x84, PayloadType.S32, False), + (0x88, PayloadType.S64, False), + (0x44, PayloadType.Float, False), + # With timestamp bit + (0x11, PayloadType.U8, True), + (0x12, PayloadType.U16, True), + (0x14, PayloadType.U32, True), + (0x18, PayloadType.U64, True), + (0x91, PayloadType.S8, True), + (0x92, PayloadType.S16, True), + (0x94, PayloadType.S32, True), + (0x98, PayloadType.S64, True), + (0x54, PayloadType.Float, True), + ], +) +def test_decode_valid(byte, expected_type, has_ts): + info = decode_payload_type(byte) + assert info.payload_type == expected_type + assert info.has_timestamp == has_ts + assert info.element_size == expected_type.numpy_dtype.itemsize + + +@pytest.mark.parametrize( + "byte", + [ + 0x00, # size nibble = 0 + 0x03, # size nibble = 3 + 0x05, # size nibble = 5 + 0x20, # reserved bit 5 set + 0xC4, # IsFloat + IsSigned + 0x41, # IsFloat + size=1 (8-bit float invalid) + 0x42, # IsFloat + size=2 + ], +) +def test_decode_invalid(byte): + with pytest.raises(ValueError): + decode_payload_type(byte) + + +def test_encode_roundtrip(): + for pt in PayloadType: + for has_ts in (False, True): + b = encode_payload_type(pt, has_timestamp=has_ts) + info = decode_payload_type(b) + assert info.payload_type == pt + assert info.has_timestamp == has_ts diff --git a/tests/protocol/test_register.py b/tests/protocol/test_register.py new file mode 100644 index 0000000..be165e4 --- /dev/null +++ b/tests/protocol/test_register.py @@ -0,0 +1,516 @@ +"""Tests for _register.py and round-trips between register format/parse.""" + +from typing import ClassVar + +import numpy as np +import pytest +from harp.data import payload_to_dataframe +from harp.protocol._message import HarpMessage +from harp.protocol._message_type import MessageType +from harp.protocol._payload import ( + PayloadBase, + PayloadFloat, + PayloadS8, + PayloadS16, + PayloadS32, + PayloadS64, + PayloadU8, + PayloadU16, + PayloadU32, + PayloadU64, + Field, + _IdentityConverter, +) +from harp.protocol._payload_type import PayloadType +from harp.protocol._register import ( + RegisterBase, + RegisterFloat, + RegisterS8, + RegisterS16, + RegisterS16Array, + RegisterS32, + RegisterS64, + RegisterU8, + RegisterU16, + RegisterU32, + RegisterU32Array, + RegisterU64, +) +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +class TimestampSecond(RegisterU32): + address: ClassVar[int] = 8 + + +class DigitalOutputSet(RegisterU16): + address: ClassVar[int] = 32 + + +class AnalogDataPayload(PayloadBase): + analog_input0 = Field(converter=_IdentityConverter(" HarpMessage: + return HarpMessage.parse(frame) + + +@pytest.mark.parametrize( + "reg_cls, address, payload_type, value, dtype", + [ + (RegisterU8, 0x10, PayloadType.U8, 42, np.dtype("u1")), + (RegisterU16, 0x11, PayloadType.U16, 1000, np.dtype(" StringConverter). + """ + from harp.protocol._payload import AnonymousPayload, Field + from harp.protocol._payload_converters import StringConverter + + class PayloadDeviceName(AnonymousPayload[np.uint8]): + __value__: str = Field(StringConverter(25)) + + class DeviceName(RegisterBase): + address: ClassVar[int] = 12 + payload_type: ClassVar[PayloadType] = PayloadType.U8 + payload_class = PayloadDeviceName + + # dtype derives from the converter (one structured slot); raw bytes are the + # encoded, null-padded value. + assert PayloadDeviceName.dtype.names == ("__value__",) + assert PayloadDeviceName.dtype.itemsize == 25 + payload = PayloadDeviceName("Behavior") + assert payload.raw_payload.tobytes() == b"Behavior".ljust(25, b"\x00") + + # Register round-trip decodes back to the high-level str, whether format() + # is given a payload instance or the bare value (symmetric with parse()). + for arg in (payload, "Behavior"): + parsed = DeviceName.parse(_parse_frame(DeviceName.format(arg))) + assert parsed == "Behavior" + + # to_dataframe decodes both a single record and a batch. + assert payload_to_dataframe(PayloadDeviceName("Behavior"))["value"].tolist() == ["Behavior"] + two = ( + PayloadDeviceName("Foo").raw_payload.tobytes() + + PayloadDeviceName("Bar").raw_payload.tobytes() + ) + batch = PayloadDeviceName.from_buffer(two) + assert payload_to_dataframe(batch)["value"].tolist() == ["Foo", "Bar"] + + +def test_anonymous_payload_scalar_converter_roundtrip(): + """A scalar (non-sub-array) ``__value__`` codec also round-trips through unwrap.""" + import enum + + from harp.protocol._payload import AnonymousPayload, Field + from harp.protocol._payload_converters import EnumConverter + + class Color(enum.IntEnum): + RED = 0 + GREEN = 1 + BLUE = 2 + + class PayloadColor(AnonymousPayload[np.uint8]): + __value__: Color = Field(EnumConverter(Color)) + + assert PayloadColor.dtype.itemsize == 1 + raw = PayloadColor(Color.BLUE).raw_payload.tobytes() + record = np.frombuffer(raw, dtype=PayloadColor.dtype, count=1)[0] + assert PayloadColor.unwrap(record) == Color.BLUE + + +def test_array_register_parse_returns_ndarray(): + """parse() on an array register returns the 1-D ndarray directly (no .value).""" + reg = RegisterU32Array(0x08, length=3) + values = np.array([10, 20, 30], dtype=np.dtype(" parse) +# --------------------------------------------------------------------------- + + +def test_scalar_registers_roundtrip(): + assert int(_roundtrip(DigitalInputs, np.uint8(0b1010))) == 0b1010 + assert int(_roundtrip(Counter0, np.int32(-100000))) == -100000 + assert int(_roundtrip(PulseDOPort0, np.uint16(5))) == 5 + assert int(_roundtrip(PulseDO0, np.uint16(9))) == 9 + + +def test_analog_data_roundtrip(): + ad = AnalogDataPayload( + Analog0=np.float32(1.0), + Analog1=np.float32(2.0), + Analog2=np.float32(3.0), + Accelerometer=np.array([4, 5, 6], dtype=np.float32), + ) + p = _roundtrip(AnalogData, ad) + assert float(p.Analog0) == 1.0 and float(p.Analog2) == 3.0 + np.testing.assert_array_equal(p.Accelerometer, [4, 5, 6]) + assert AnalogDataPayload.dtype.itemsize == 24 # 6 floats + + +def test_version_roundtrip(): + ver = VersionPayload( + ProtocolVersion=HarpVersion(2, 0, 0), + FirmwareVersion=HarpVersion(1, 2, 3), + HardwareVersion=HarpVersion(1, 0, 0), + CoreId="abc", + InterfaceHash=np.arange(20, dtype=np.uint8), + ) + p = _roundtrip(Version, ver) + assert p.ProtocolVersion == HarpVersion(2, 0, 0) + assert p.CoreId == "abc" + np.testing.assert_array_equal(p.InterfaceHash, np.arange(20)) + assert VersionPayload.dtype.itemsize == 32 + + +def test_custom_member_converter_roundtrip(): + p = _roundtrip( + CustomMemberConverter, CustomMemberConverterPayload(Header=np.uint8(7), Data=-1234) + ) + assert int(p.Header) == 7 and int(p.Data) == -1234 + + +def test_encoder_mode_roundtrip(): + # Single whole-register groupMask -> parse() unwraps to the bare enum. + p = _roundtrip(EncoderMode, EncoderModeMask.Displacement) + assert p == EncoderModeMask.Displacement + assert isinstance(p, EncoderModeMask) + + +# --------------------------------------------------------------------------- +# Offsets + gaps (ComplexConfiguration) +# --------------------------------------------------------------------------- + + +def test_complex_configuration_gap_and_offsets(): + cc = ComplexConfigurationPayload( + PwmPort=PwmPort.Pwm2, + DutyCycle=np.float32(0.5), + Frequency=np.float32(1000.0), + EventsEnabled=True, + Delta=np.uint32(42), + ) + # itemsize from the register length (17), not the member extent. + assert ComplexConfigurationPayload.dtype.itemsize == 17 + # bytes 1..3 are an uncovered gap, preserved on encode. + assert cc.raw_payload.tobytes()[1:4] == b"\x00\x00\x00" + # explicit byte offsets (base element = uint8, so element units == bytes). + fields = ComplexConfigurationPayload.dtype.fields + assert fields["DutyCycle"][1] == 4 + assert fields["Delta"][1] == 13 + + p = _roundtrip(ComplexConfiguration, cc) + assert p.PwmPort == PwmPort.Pwm2 + assert float(p.DutyCycle) == 0.5 + assert p.EventsEnabled is True + assert int(p.Delta) == 42 + + +# --------------------------------------------------------------------------- +# Masked overlap on one element (StartPulse / StartPulseTrain / BitmaskSplitter) +# --------------------------------------------------------------------------- + + +def test_start_pulse_overlapping_masks(): + # Two views of one U16 element share storage (one numpy field, itemsize 2). + assert StartPulsePayload.dtype.itemsize == 2 + assert len(StartPulsePayload.dtype.names) == 1 + p = _roundtrip( + StartPulse, StartPulsePayload(DigitalOutput=PwmPort.Pwm1, PulseWidth=np.uint16(300)) + ) + assert p.DigitalOutput == PwmPort.Pwm1 + assert int(p.PulseWidth) == 300 + + +def test_start_pulse_train_two_words_and_default(): + p = _roundtrip( + StartPulseTrain, + StartPulseTrainPayload( + DigitalOutput=PwmPort.Pwm1, + PulseWidth=np.uint16(300), + Frequency=np.uint8(200), + PulseCount=np.uint8(50), + ), + ) + assert p.DigitalOutput == PwmPort.Pwm1 and int(p.PulseWidth) == 300 + assert int(p.Frequency) == 200 and int(p.PulseCount) == 50 + assert StartPulseTrainPayload.dtype.itemsize == 4 # two U16 words + # defaultValue: Frequency defaults to 1 when not provided. + assert int(StartPulseTrainPayload(PulseCount=np.uint8(3)).Frequency) == 1 + + +def test_bitmask_splitter_masked_ints(): + p = _roundtrip(BitmaskSplitter, BitmaskSplitterPayload(Low=0xA, High=0x5)) + assert int(p.Low) == 0xA and int(p.High) == 0x5 + assert p.raw_payload.tobytes() == bytes([0x5A]) # High packs into the top nibble + + +def test_port_dio_set_bitmask(): + # Single whole-register bitMask -> parse() unwraps to the bare IntFlag. + p = _roundtrip(PortDIOSet, PortDigitalIOS.DIO0 | PortDigitalIOS.DIO3) + assert p == PortDigitalIOS.DIO0 | PortDigitalIOS.DIO3 + assert PortDigitalIOS.DIO1 not in p + assert PortDIOSetPayload.dtype.itemsize == 1 + + +# --------------------------------------------------------------------------- +# Register-level interfaceType: single full-span member unwraps on parse +# --------------------------------------------------------------------------- + + +def test_custom_payload_single_member_unwrap(): + # Root payload: single __value__ view, unwrapped on parse. + assert CustomPayloadPayload._single_member == "__value__" + assert CustomPayloadPayload._root is True + # U32[3] HarpVersion -> 12-byte buffer (3 x u32), same converter class. + assert CustomPayloadPayload.dtype.itemsize == 12 + parsed = _roundtrip(CustomPayload, CustomPayloadPayload(HarpVersion(3, 1, 4))) + assert isinstance(parsed, HarpVersion) + assert parsed == HarpVersion(3, 1, 4) + + +# --------------------------------------------------------------------------- +# Strict enums: an out-of-range masked code raises +# --------------------------------------------------------------------------- + + +def test_strict_enum_raises_on_unknown_code(): + # StartPulse.DigitalOutput is a 2-bit field; code 0b11 has no PwmPort member. + raw = np.array(0b11 << 10, dtype=np.uint16).tobytes() + payload = StartPulsePayload.from_buffer(raw) + with pytest.raises(ValueError): + _ = payload.DigitalOutput + + +# --------------------------------------------------------------------------- +# to_dataframe over masked + offset payloads +# --------------------------------------------------------------------------- + + +def test_complex_configuration_to_dataframe(): + cc = ComplexConfigurationPayload( + PwmPort=PwmPort.Pwm2, + DutyCycle=np.float32(0.5), + Frequency=np.float32(1.0), + EventsEnabled=True, + Delta=np.uint32(42), + ) + batch = ComplexConfigurationPayload.from_buffer(cc.raw_payload.tobytes() * 2) + df = payload_to_dataframe(batch) + assert len(df) == 2 + assert list(df["PwmPort"]) == ["Pwm2", "Pwm2"] + np.testing.assert_array_equal(df["Delta"], [42, 42]) diff --git a/tests/test_device.py b/tests/test_device.py deleted file mode 100644 index da16859..0000000 --- a/tests/test_device.py +++ /dev/null @@ -1,86 +0,0 @@ -import serial - -from typing import Optional -from pyharp.messages import HarpMessage, ReplyHarpMessage -from pyharp.device import Device - -DEFAULT_ADDRESS = 42 - - -def test_create_device() -> None: - # open serial connection and load info - device = Device("/dev/tty.usbserial-A106C8O9") - assert device._ser.is_open - device.info() - device.disconnect() - assert not device._ser.is_open - - -def test_read_U8() -> None: - # open serial connection and load info - device = Device("/dev/tty.usbserial-A106C8O9", "dump.bin") - - # read register 38 - register: int = 38 - read_size: int = 35 # TODO: automatically calculate this! - - read_message = HarpMessage.ReadU8(register) - reply: ReplyHarpMessage = device.send(read_message.frame) - assert reply is not None - # assert reply.payload_as_int() == write_value - - print(reply) - assert device._dump_file_path.exists() - device.disconnect() - - -def test_U8() -> None: - # open serial connection and load info - device = Device("/dev/tty.usbserial-A106C8O9", "dump.txt") - assert device._dump_file_path.exists() - - register: int = 38 - read_size: int = 20 # TODO: automatically calculate this! - write_value: int = 65 - - # assert reply[11] == 0 # what is the default register value?! - - # write 65 on register 38 - write_message = HarpMessage.WriteU8(register, write_value) - reply : ReplyHarpMessage = device.send(write_message.frame) - assert reply is not None - - # read register 38 - read_message = HarpMessage.ReadU8(register) - reply = device.send(read_message.frame) - assert reply is not None - assert reply.payload_as_int() == write_value - - device.disconnect() - - -# def test_read_hw_version_integration() -> None: -# -# # serial settings -# ser = serial.Serial( -# "/dev/tty.usbserial-A106C8O9", -# baudrate=1000000, -# timeout=5, -# parity=serial.PARITY_NONE, -# stopbits=1, -# bytesize=8, -# rtscts=True, -# ) -# -# assert ser.is_open -# -# ser.write(b"\x01\x04\x01\xff\x01\x06") # read HW major version (register 1) -# ser.write(b"\x01\x04\x02\xff\x01\x07") # read HW minor version (register 2) -# # print(f"In waiting: <{ser.in_waiting}>") -# -# data = ser.read(100) -# print(f"Data: {data}") -# ser.close() -# assert not ser.is_open -# -# # assert data[0] == '\t' diff --git a/tests/test_messages.py b/tests/test_messages.py deleted file mode 100644 index 1100ae9..0000000 --- a/tests/test_messages.py +++ /dev/null @@ -1,85 +0,0 @@ -from pyharp.messages import HarpMessage -from pyharp.messages import MessageType -from pyharp.messages import CommonRegisters - -DEFAULT_ADDRESS = 42 - - -def test_create_read_U8() -> None: - message = HarpMessage.ReadU8(DEFAULT_ADDRESS) - - assert message.message_type == MessageType.READ - assert message.checksum == 47 # 1 + 4 + 42 + 255 + 1 - 256 - print(message.frame) - - -def test_create_read_S8() -> None: - message = HarpMessage.ReadS8(DEFAULT_ADDRESS) - - assert message.message_type == MessageType.READ - assert message.checksum == 175 # 1 + 4 + 42 + 255 + 129 - 256 - print(message.frame) - - -def test_create_read_U16() -> None: - message = HarpMessage.ReadU16(DEFAULT_ADDRESS) - - assert message.message_type == MessageType.READ - assert message.checksum == 48 # 1 + 4 + 42 + 255 + 2 - 256 - print(message.frame) - - -def test_create_read_S16() -> None: - message = HarpMessage.ReadS16(DEFAULT_ADDRESS) - - assert message.message_type == MessageType.READ - assert message.checksum == 176 # 1 + 4 + 42 + 255 + 130 - 256 - print(message.frame) - - -def test_create_write_U8() -> None: - value: int = 23 - message = HarpMessage.WriteU8(DEFAULT_ADDRESS, value) - - assert message.message_type == MessageType.Write - assert message.payload == value - assert message.checksum == 72 # 2 + 5 + 42 + 255 + 1 + 23 - 256 - print(message.frame) - - -def test_create_write_S8() -> None: - value: int = -3 # corresponds to signed int 253 (0xFD) - message = HarpMessage.WriteS8(DEFAULT_ADDRESS, value) - - assert message.message_type == MessageType.Write - assert message.payload == value - assert message.checksum == 174 # (2 + 5 + 42 + 255 + 129 + 253) & 255 - print(message.frame) - - -def test_create_write_U16() -> None: - value: int = 1024 # 4 0 (2 x bytes) - message = HarpMessage.WriteU16(DEFAULT_ADDRESS, value) - - assert message.message_type == MessageType.Write - assert message.length == 6 - assert message.payload == value - assert message.checksum == 55 # (2 + 6 + 42 + 255 + 2 + 4 + 0) & 255 - print(message.frame) - - -def test_create_write_S16() -> None: - value: int = -4837 # 27 237 (2 x bytes), corresponds to signed int 7149 - message = HarpMessage.WriteS16(DEFAULT_ADDRESS, value) - - assert message.message_type == MessageType.Write - assert message.length == 6 - assert message.payload == value - assert message.checksum == 187 # (2 + 6 + 42 + 255 + 130 + 27 + 237) & 255 - print(message.frame) - - -def test_read_who_am_i() -> None: - message = HarpMessage.ReadU16(CommonRegisters.WHO_AM_I) - - assert str(message.frame) == str(bytearray(b"\x01\x04\x00\xff\x02\x06")) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a3002e6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1300 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[manifest] +members = [ + "harp", + "harp-benchmarks", + "harp-data", + "harp-device", + "harp-protocol", + "harp-serial", +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + +[[package]] +name = "bracex" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/f5/4473ad9b48cd0420a2d762a3750fa0e078e23e060b1af72662e5987e5530/bracex-3.0.tar.gz", hash = "sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111", size = 43162, upload-time = "2026-06-30T00:43:35.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/2e/68781b78e764e5ccc4af1e3d27e060069c73af90234853fa80000e7ee79d/bracex-3.0-py3-none-any.whl", hash = "sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5", size = 11738, upload-time = "2026-06-30T00:43:34.196Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, +] + +[[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.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "fieldz" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/46/7a8d1db959eabd5d3e52bd3c000a8b132184b7651cbd5481c4cbf31ff65d/fieldz-0.2.0.tar.gz", hash = "sha256:e11215188cad5c5371113d2b7707155960efd0d48500b6bf675648bc3f6fc8d6", size = 18222, upload-time = "2026-02-24T19:26:02.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/9d/9034acaf3d80e85deb3d49876cb734479327b9cf89a918815ef67cb6b36c/fieldz-0.2.0-py3-none-any.whl", hash = "sha256:43b5be702816df39f55d08ae392eaabe58d3c69d2e4b61a853252cb2da41931f", size = 17946, upload-time = "2026-02-24T19:26:00.931Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe-fieldz" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fieldz" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/7f/b57a27807536590f0cabdd2e6c0faac78c7d6a740e096684c51c503b7003/griffe_fieldz-0.5.0.tar.gz", hash = "sha256:dcf237a0def9f5c15e15aa5cf07b3d0a4edf4f7d6a2d713a45afe29c8af9bc8e", size = 14250, upload-time = "2026-02-24T16:22:23.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/66/9daa3118d9bf4443778d6ee31b024c79766eeb116b275eca71597adae43a/griffe_fieldz-0.5.0-py3-none-any.whl", hash = "sha256:d363e56a7468019a7184ea3995277a1f20507bb9a345408c1f7014ae06878ebf", size = 9629, upload-time = "2026-02-24T16:22:22.094Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + +[[package]] +name = "harp" +source = { editable = "." } +dependencies = [ + { name = "harp-data" }, + { name = "harp-device" }, + { name = "harp-protocol" }, + { name = "harp-serial" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codespell" }, + { name = "harp-benchmarks" }, + { name = "harp-data" }, + { name = "harp-device" }, + { name = "harp-protocol" }, + { name = "harp-serial" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "typing-extensions" }, +] +docs = [ + { name = "griffe-fieldz" }, + { name = "mkdocs" }, + { name = "mkdocs-codeinclude-plugin" }, + { name = "mkdocs-git-authors-plugin" }, + { name = "mkdocs-include-markdown-plugin" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings-python" }, +] + +[package.metadata] +requires-dist = [ + { name = "harp-data", editable = "src/packages/harp-data" }, + { name = "harp-device", editable = "src/packages/harp-device" }, + { name = "harp-protocol", editable = "src/packages/harp-protocol" }, + { name = "harp-serial", editable = "src/packages/harp-serial" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell", specifier = ">=2.3.0" }, + { name = "harp-benchmarks", editable = "src/packages/harp-benchmarks" }, + { name = "harp-data", editable = "src/packages/harp-data" }, + { name = "harp-device", editable = "src/packages/harp-device" }, + { name = "harp-protocol", editable = "src/packages/harp-protocol" }, + { name = "harp-serial", editable = "src/packages/harp-serial" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "ruff", specifier = ">=0.11.0" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, +] +docs = [ + { name = "griffe-fieldz", specifier = ">=0.2.1" }, + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-codeinclude-plugin", specifier = ">=0.2.1" }, + { name = "mkdocs-git-authors-plugin", specifier = ">=0.9.4" }, + { name = "mkdocs-include-markdown-plugin", specifier = ">=7.1.6" }, + { name = "mkdocs-material", specifier = ">=9.6.9" }, + { name = "mkdocstrings-python", specifier = ">=1.16.6" }, +] + +[[package]] +name = "harp-benchmarks" +source = { editable = "src/packages/harp-benchmarks" } +dependencies = [ + { name = "harp-data" }, + { name = "harp-protocol" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, +] + +[package.metadata] +requires-dist = [ + { name = "harp-data", editable = "src/packages/harp-data" }, + { name = "harp-protocol", editable = "src/packages/harp-protocol" }, + { name = "numpy", specifier = ">=1.24" }, + { name = "pandas", specifier = ">=2.0" }, +] + +[[package]] +name = "harp-data" +source = { editable = "src/packages/harp-data" } +dependencies = [ + { name = "harp-protocol" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, +] + +[package.metadata] +requires-dist = [ + { name = "harp-protocol", editable = "src/packages/harp-protocol" }, + { name = "numpy", specifier = ">=1.24" }, + { name = "pandas", specifier = ">=2.0" }, +] + +[[package]] +name = "harp-device" +source = { editable = "src/packages/harp-device" } +dependencies = [ + { name = "harp-protocol" }, +] + +[package.metadata] +requires-dist = [{ name = "harp-protocol", editable = "src/packages/harp-protocol" }] + +[[package]] +name = "harp-protocol" +source = { editable = "src/packages/harp-protocol" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=1.24" }, + { name = "typing-extensions", specifier = ">=4.0" }, +] + +[[package]] +name = "harp-serial" +source = { editable = "src/packages/harp-serial" } +dependencies = [ + { name = "harp-device" }, + { name = "harp-protocol" }, + { name = "pyserial" }, +] + +[package.metadata] +requires-dist = [ + { name = "harp-device", editable = "src/packages/harp-device" }, + { name = "harp-protocol", editable = "src/packages/harp-protocol" }, + { name = "pyserial", specifier = ">=3.5" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-codeinclude-plugin" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/3b/86c32ce13d966f00c7e05fda0ae824bd7a386a49958a4b18566f3896482d/mkdocs_codeinclude_plugin-0.3.1.tar.gz", hash = "sha256:06bbbf0d4ac7eccaec6e0d89ce76d644a197cfed34880f541516e722ded6512a", size = 12988, upload-time = "2026-02-20T19:11:56.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/ec/fd55b2d0b5e98f002a4bb4ce2b0e6f146703b99a93378fc363e8d9d6ef3e/mkdocs_codeinclude_plugin-0.3.1-py3-none-any.whl", hash = "sha256:443f32c9e4412b84ec084bd2b454020c5bf06cb9a958682e08a528e62b45da4d", size = 8610, upload-time = "2026-02-20T19:11:57.694Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-git-authors-plugin" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/f1/b784c631b812aab80030db80127a576b68a84caac5229836fb7fcc00e055/mkdocs_git_authors_plugin-0.10.0.tar.gz", hash = "sha256:29d1973b2835663d79986fb756e02f1f0ff3fe35c278e993206bd3c550c205e4", size = 23432, upload-time = "2025-06-10T05:42:40.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/bc/a4166201c2789657c4d370bfcd71a5107edec185ae245675c8b9a6719243/mkdocs_git_authors_plugin-0.10.0-py3-none-any.whl", hash = "sha256:28421a99c3e872a8e205674bb80ec48524838243e5f59eaf9bd97df103e38901", size = 21899, upload-time = "2025-06-10T05:42:39.244Z" }, +] + +[[package]] +name = "mkdocs-include-markdown-plugin" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/2b/c788fc5c39ccba342eb9067be637327faebbc0e47da41ea79dc2526e693a/mkdocs_include_markdown_plugin-7.3.0.tar.gz", hash = "sha256:2800126746452e31c2e321bbd43c8190b356e0de353e20cbc16a34a3c3d6796c", size = 25527, upload-time = "2026-05-15T18:16:13.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/84/c0109c5991b89cbf028b8597f73fa7bd6a6b092407be2369a354b52737ab/mkdocs_include_markdown_plugin-7.3.0-py3-none-any.whl", hash = "sha256:5b5c99b5d3c9b9ce0114a9e60353bbafb6be53a26c2d3b74ec6b767a7a8e55ca", size = 29675, upload-time = "2026-05-15T18:16:12.654Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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.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 = "pymdown-extensions" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +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.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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 = "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 = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[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.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" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcmatch" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/25/1da725838132221e33568973da484ff43813662ccc06ebf7f6e3abddfcd5/wcmatch-11.0.tar.gz", hash = "sha256:55d95c2447789712774b198ceec72939e88b5618f1f8f0a9b605bf7740b63b96", size = 141360, upload-time = "2026-07-10T05:50:24.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/12/f38b6fee116274d7221743caab07d765032e1370bb54cad8714f87aeb0e8/wcmatch-11.0-py3-none-any.whl", hash = "sha256:3a5977ace27e075eef67eb03d539563f1a19018b62881949a42932cf66926934", size = 42914, upload-time = "2026-07-10T05:50:22.995Z" }, +]