diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..085abe7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run tests + run: uv run pytest tests/ -v + + - name: Run mypy + run: uv run mypy src/buttplug/ + + - name: Run ruff check + run: uv run ruff check src/buttplug/ + + - name: Run ruff format check + run: uv run ruff format --check src/buttplug/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7436abd..df78d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# 0.3.0 (2022-08-06) + +## Breaking Changes + +- device_removed_handlers now correctly receive the removed ButtplugClientDevice rather than its integer id. + +# 0.2.1 (2021-12-12) + +## Bug Fixes + +- Change print statements to logging calls so we don't interrupt other libraries. +- Update to websockets 10 for security issues. + # 0.2.0 (2020-05-10) ## Bug Fixes diff --git a/Pipfile b/Pipfile deleted file mode 100644 index f29841c..0000000 --- a/Pipfile +++ /dev/null @@ -1,12 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -websockets = "==7.0" - -[requires] -python_version = "3.8" diff --git a/README.md b/README.md index 9772411..db7e14d 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,148 @@ -# DEPRECATION WARNING - -This project will be deprecated and archived in the coming weeks/months, with -the Python implementation of buttplug moving to an FFI layer on top of -buttplug-rs. Bugs are currently being triaged from this library to -buttplug-rs-ffi. - -The new project will be at - -[https://github.com/buttplugio/buttplug-rs-ffi/](https://github.com/buttplugio/buttplug-rs-ffi/) - -The API will change, though minimally (mostly connection in methods), and we -will most likely still distribute the pypi package under the same name -("buttplug"). - -You may continue to use this repo for the time being, just wanted everyone to be -aware of the changes happening in the near future. - # buttplug-py [![PyPi version](https://img.shields.io/pypi/v/buttplug)](http://pypi.org/project/buttplug) [![Python version](https://img.shields.io/pypi/pyversions/buttplug)](http://pypi.org/project/buttplug) [![Patreon donate button](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://www.patreon.com/qdot) -[![Discourse Forum](https://img.shields.io/badge/discourse-forum-blue.svg)](https://metafetish.club) [![Discord](https://img.shields.io/discord/353303527587708932.svg?logo=discord)](https://discord.buttplug.io) [![Twitter](https://img.shields.io/twitter/follow/buttplugio.svg?style=social&logo=twitter)](https://twitter.com/buttplugio) -Buttplug-py is a python implementation of the Core and Client portions -of the Buttplug Sex Toy Control Protocol. It allows users to write -applications that can connect to Buttplug Servers, such as the -[Intiface Desktop -Application](https://github.com/intiface/intiface-desktop) or Intiface -[C# CLI](https://github.com/intiface/intiface-cli-csharp) or [Node -CLI](https://github.com/intiface/intiface-cli-node). +Python client library for the [Buttplug](https://buttplug.io) Intimate Hardware Control Protocol (v4). -A python-based Buttplug server is certainly possible, and may happen -in the future. For the moment, we are mostly trying to make it easier -for people to write Buttplug applications in python that can access -the already existing server implementations. +## Installation -For more information on the Buttplug project, check out the project -website at [https://buttplug.io](https://buttplug.io). +```bash +pip install buttplug +``` -## Table Of Contents +Or with [uv](https://github.com/astral-sh/uv): -- [Support The Project](#support-the-project) -- [Documentation](#documentation) -- [Examples](#examples) -- [License](#license) +```bash +uv add buttplug +``` -## Support The Project +## Quick Start -If you find this project helpful, you can [support us via -Patreon](http://patreon.com/qdot)! Every donation helps us afford more -hardware to reverse, document, and write code for! +1. **Install and start [Intiface Central](https://intiface.com/central/)** - This is the server that connects to your devices. -## Documentation +2. **Connect and control devices:** + +```python +import asyncio +from buttplug import ButtplugClient, DeviceOutputCommand, OutputType + +async def main(): + # Create a client + client = ButtplugClient("My App") + + # Connect to Intiface Central (default address) + await client.connect("ws://127.0.0.1:12345") + + # Scan for devices + await client.start_scanning() + await asyncio.sleep(5) # Wait for devices to be found + await client.stop_scanning() + + # Control devices + for device in client.devices.values(): + print(f"Found: {device.name}") + + if device.has_output(OutputType.VIBRATE): + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) + await asyncio.sleep(2) + await device.stop() + + await client.disconnect() + +asyncio.run(main()) +``` + +## Features + +- **Simple API**: Unified `run_output()` method for all output types +- **Full Protocol Support**: Implements Buttplug protocol v4 +- **Type Hints**: Full typing support for IDE autocomplete and type checking +- **Async/Await**: Modern Python async API +- **Event Callbacks**: Get notified when devices connect/disconnect -Library and API Documentation for buttplug-py is available at +## Device Control -https://buttplug-py.docs.buttplug.io +```python +from buttplug import DeviceOutputCommand, OutputType -Other recommended reading includes +# Check device capabilities and send commands +if device.has_output(OutputType.VIBRATE): + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.75)) -- [The Buttplug Protocol Spec](https://buttplug-spec.docs.buttplug.io) -- [The Buttplug Developer Guide](https://buttplug-developer-guide.docs.buttplug.io) +if device.has_output(OutputType.ROTATE): + await device.run_output(DeviceOutputCommand(OutputType.ROTATE, 0.5)) + +if device.has_output(OutputType.POSITION_WITH_DURATION): + await device.run_output( + DeviceOutputCommand(OutputType.POSITION_WITH_DURATION, 1.0, duration=500) + ) + +# Read sensors +if device.has_input(InputType.BATTERY): + battery = await device.battery() + print(f"Battery: {battery * 100:.0f}%") + +# Stop device +await device.stop() +``` + +## Event Handling + +```python +# Set up callbacks before connecting +client.on_device_added = lambda d: print(f"Connected: {d.name}") +client.on_device_removed = lambda d: print(f"Disconnected: {d.name}") +client.on_scanning_finished = lambda: print("Scan complete") +client.on_server_disconnect = lambda: print("Server disconnected!") + +# Async callbacks are also supported +async def on_device_added(device): + if device.has_output(OutputType.VIBRATE): + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.25)) + +client.on_device_added = on_device_added +``` ## Examples -Example code is available in the examples/ directory. Examples are -heavily commented to hopefully make usage of the library clearer. +See the [examples/](examples/) directory for more detailed examples: + +- `application.py` - Complete application workflow +- `connection.py` - Connecting to a server +- `device_control.py` - Vibrate, rotate, and position commands +- `device_enumeration.py` - Discovering devices +- `device_info.py` - Inspecting device features +- `sensors.py` - Battery and signal strength +- `errors.py` - Error handling + +To run examples from within the repo: + +```bash +uv sync +uv run python examples/application.py +``` + +## Requirements + +- Python 3.10+ +- [Intiface Central](https://intiface.com/central/) or another Buttplug server + +## Documentation + +- [Buttplug Developer Guide](https://docs.buttplug.io) +- [Protocol Specification](https://docs.buttplug.io/docs/spec) + +## Support + +- [Discord](https://discord.buttplug.io) - Community chat and support +- [GitHub Issues](https://github.com/buttplugio/buttplug-py/issues) - Bug reports and feature requests +- [Patreon](https://patreon.com/qdot) / [GitHub Sponsors](https://github.com/sponsors/qdot) - Support development ## License -Buttplug is BSD 3-Clause licensed. More information is available in -the LICENSE file. +BSD 3-Clause. See [LICENSE](LICENSE) for details. diff --git a/buttplug/__init__.py b/buttplug/__init__.py deleted file mode 100644 index 4e1273d..0000000 --- a/buttplug/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.2.0" -name = "buttplug" diff --git a/buttplug/client/__init__.py b/buttplug/client/__init__.py deleted file mode 100644 index cb8e549..0000000 --- a/buttplug/client/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .client import ButtplugClient, ButtplugClientDevice -from .connector import ButtplugClientConnector, ButtplugClientConnectorError -from .websocket_connector import ButtplugClientWebsocketConnector - -__all__ = ["ButtplugClient", "ButtplugClientConnector", - "ButtplugClientWebsocketConnector", "ButtplugClientDevice", - "ButtplugClientConnectorError"] diff --git a/buttplug/client/client.py b/buttplug/client/client.py deleted file mode 100644 index 00f1a6a..0000000 --- a/buttplug/client/client.py +++ /dev/null @@ -1,359 +0,0 @@ -# Buttplug Python -# Client Module -# Copyright 2019 Nonpolynomial -# 3-Clause BSD Licensed - -from .connector import (ButtplugClientConnector, - ButtplugClientConnectorObserver, - ButtplugClientConnectorError) -from ..core import (ButtplugMessage, StartScanning, StopScanning, Ok, - RequestServerInfo, Error, ServerInfo, - ButtplugMessageError, RequestLog, DeviceAdded, - DeviceList, DeviceRemoved, ScanningFinished, DeviceInfo, - MessageAttributes, VibrateCmd, SpeedSubcommand, - RequestDeviceList, RotateSubcommand, LinearSubcommand, - RotateCmd, LinearCmd, StopDeviceCmd, ButtplugErrorCode, - ButtplugError, Log, ButtplugDeviceError, - ButtplugHandshakeError, ButtplugPingError, - ButtplugUnknownError) -from ..utils import EventHandler -from typing import Dict, List, Tuple, Union -from asyncio import Future, get_event_loop - - -class ButtplugClient(ButtplugClientConnectorObserver): - """Used to connect to Buttplug Servers. - - Attributes: - - name (string): - name of the client, which the server can use to show - with connection status. - - devices (Dict[int, ButtplugClientDevice]): - dict of devices currently connected to the Buttplug Server, indexed - by their server-provisioned numerical index. - - device_added_handler (buttplug.utils.EventHandler): - Takes functions of the format f(a: ButtplugClientDevice) -> void. - Calls handlers whenever a new device is found by the Buttplug - Server. - - device_removed_handler (buttplug.utils.EventHandler): - Takes functions of the format f(a: ButtplugClientDevice) -> void. - Calls handlers whenever a device has disconnected from the Buttplug - server. - - scanning_finished_handler (buttplug.utils.EventHandler): - Takes functions of the format f() -> void. Calls handlers whenever - the server has finished scanning for devices. - - log_handler (buttplug.utils.EventHandler): - Takes functions of the format f(a: Log) -> void. Calls handlers - whenever a new log message is received. - """ - def __init__(self, name: str): - self.name: str = name - self.connector: ButtplugClientConnector = None - self.devices: Dict[int, ButtplugClientDevice] = {} - self.scanning_finished_handler: EventHandler = EventHandler(self) - self.device_added_handler: EventHandler = EventHandler(self) - self.device_removed_handler: EventHandler = EventHandler(self) - self.log_handler: EventHandler = EventHandler(self) - self._msg_tasks: Dict[int, Future] = {} - self._msg_counter: int = 1 - - async def connect(self, connector): - """Connects to a Buttplug Server, using the connector passed to it. - - Asynchronous function that connects to a Buttplug Server. - - Args: - connector (ButtplugConnector): - Connector to use to contact the server. - - Returns: - void: - Should just return on successful connect. - - Raises: - buttplug.client.ButtplugClientConnectorError: - On failed connect. Check message for context. - """ - self.connector = connector - self.connector.add_observer(self) - await self.connector.connect() - await self._init() - - async def _init(self): - initmsg = RequestServerInfo(self.name) - msg: ServerInfo = await self._send_message_expect_reply(initmsg, - ServerInfo) - print("Connected to server: " + msg.server_name) - dl: DeviceList = await self._send_message_expect_reply(RequestDeviceList(), - DeviceList) - self._handle_device_list(dl) - - def _handle_device_list(self, dl: DeviceList): - for dev in dl.devices: - self.devices[dev.device_index] = ButtplugClientDevice(self, dev) - self.device_added_handler(self.devices[dev.device_index]) - - async def disconnect(self): - """Disconnect from the remote server. - """ - if not self.connector.connected: - return - await self.connector.disconnect() - - async def start_scanning(self): - """Request that the server starts scanning for devices. - """ - await self._send_message_expect_ok(StartScanning()) - - async def stop_scanning(self): - """Request that the server stops scanning for devices. - """ - await self._send_message_expect_ok(StopScanning()) - - async def request_log(self, log_level: str): - """Request that the server sends logs at the requested level or higher to the - client. - - To stop logs from being sent, call request_log again with the "Off" - level. - - Args: - log_level (string): - Log level to receive. Send "Off" to stop logs from being sent. - """ - await self._send_message_expect_ok(RequestLog(log_level)) - - async def _send_message(self, msg: ButtplugMessage): - msg.id = self._msg_counter - self._msg_counter += 1 - await self.connector.send(msg) - - async def _parse_message(self, msg: ButtplugMessage): - if isinstance(msg, DeviceAdded): - da: DeviceAdded = msg - self.devices[da.device_index] = ButtplugClientDevice(self, da) - self.device_added_handler(self.devices[da.device_index]) - elif isinstance(msg, DeviceRemoved): - dr: DeviceRemoved = msg - self.devices.pop(dr.device_index) - self.device_removed_handler(dr.device_index) - elif isinstance(msg, ScanningFinished): - self.scanning_finished_handler() - elif isinstance(msg, Log): - self.log_handler(Log) - - # What kinda typing should expectedClass be here? Could we make this a - # generic function? - async def _send_message_expect_reply(self, - msg: ButtplugMessage, - expectedClass) -> ButtplugMessage: - if not self.connector.connected: - raise ButtplugClientConnectorError("Client not connected to server") - f = get_event_loop().create_future() - await self._send_message(msg) - self._msg_tasks[msg.id] = f - retmsg = await f - if not isinstance(retmsg, expectedClass): - if isinstance(retmsg, Error): - # This will always throw - self._throw_error_msg_exception(retmsg) - raise ButtplugMessageError("Unexpected message" + retmsg) - return retmsg - - async def _send_message_expect_ok(self, msg: ButtplugMessage) -> None: - await self._send_message_expect_reply(msg, Ok) - - async def _handle_message(self, msg: ButtplugMessage): - if msg.id in self._msg_tasks.keys(): - self._msg_tasks[msg.id].set_result(msg) - return - await self._parse_message(msg) - - def _throw_error_msg_exception(self, msg: Error): - if msg.error_code == ButtplugErrorCode.ERROR_UNKNOWN: - raise ButtplugUnknownError(msg) - elif msg.error_code == ButtplugErrorCode.ERROR_DEVICE: - raise ButtplugDeviceError(msg) - elif msg.error_code == ButtplugErrorCode.ERROR_MSG: - raise ButtplugMessageError(msg) - elif msg.error_code == ButtplugErrorCode.ERROR_PING: - raise ButtplugPingError(msg) - elif msg.error_code == ButtplugErrorCode.ERROR_INIT: - raise ButtplugHandshakeError(msg) - raise ButtplugError(msg) - - -class ButtplugClientDevice(object): - """Represents a device that is connected to the Buttplug Server. - - Attributes: - - name (string): - Name of the device - - allowed_messages (Dict[str, MessageAttributes]): - Dictionary that matches message names to attributes. For instance, - if a device can vibrate, it will have a dictionary entry for - "VibrateCmd", as well as a MessageAttribute for "FeatureCount" that - says how many vibrators are in the device. - """ - def __init__(self, client: ButtplugClient, device_msg: Union[DeviceInfo, - DeviceAdded]): - self._client = client - if isinstance(device_msg, DeviceInfo): - device_info: DeviceInfo = device_msg - self.name = device_info.device_name - self._index = device_info.device_index - self.allowed_messages: Dict[str, MessageAttributes] = {} - for (msg_name, attrs) in device_info.device_messages.items(): - self.allowed_messages[msg_name] = MessageAttributes(attrs.get("FeatureCount")) - elif isinstance(device_msg, DeviceAdded): - device_info: DeviceAdded = device_msg - self.name = device_info.device_name - self._index = device_info.device_index - self.allowed_messages: Dict[str, MessageAttributes] = {} - print(device_info.device_messages) - for (msg_name, attrs) in device_info.device_messages.items(): - self.allowed_messages[msg_name] = MessageAttributes(attrs.get("FeatureCount")) - else: - raise ButtplugDeviceError( - "Cannot create device from message {}".format(device_msg.__name__)) - - async def send_vibrate_cmd(self, speeds: Union[float, - List[float], - Dict[int, float]]): - """Tell the server to make a device vibrate at a certain speed. 0.0 for speed - or using send_stop_device_cmd will stop the hardware from vibrating. - - Args: - speeds (Union[float, List[float], Dict[int, float]]): - Speed, or speeds, to set the vibrators to, assuming the - hardware supports vibration. Range is from 0.0 <= x <= 1.0. - - Types accepted: - - - a single float, which all vibration motors will be set to - - - a list of floats, mapping to the motor indexes in the - hardware, i.e. [0.5, 1.0] will set motor 0 to 0.5, motor 1 to - 1. - - - a dict of int to float, which maps motor index to speed. i.e. - { 0: 0.5, 1: 1.0 } will set motor 0 to 0.5, motor 1 to 1. - - """ - if "VibrateCmd" not in self.allowed_messages.keys(): - raise ButtplugDeviceError("VibrateCmd not supported by device") - speeds_obj = [] - if isinstance(speeds, (float, int)): - speeds_obj = [SpeedSubcommand(0, speeds)] - elif isinstance(speeds, list): - speeds_obj = [SpeedSubcommand(x, speed) - for x, speed in enumerate(speeds)] - elif isinstance(speeds, dict): - speeds_obj = [SpeedSubcommand(x, speed) - for x, speed in speeds.items()] - - msg = VibrateCmd(self._index, - speeds_obj) - await self._client._send_message_expect_ok(msg) - - async def send_rotate_cmd(self, rotations: Union[Tuple[float, bool], - List[Tuple[float, bool]], - Dict[int, Tuple[float, bool]]]): - """Tell the server to make a device rotate at a certain speed. 0.0 for speed or - using send_stop_device_cmd will stop the hardware from rotating. - - Args: - rotations (Union[Tuple[float, bool], List[Tuple[float, bool]], Dict[int, Tuple[float, bool]]]): - Rotation speed(s) and directions, to set the hardware to, - assuming the hardware supports rotation.. Range is from 0.0 <= - x <= 1.0 for speeds. For bool, True is clockwise direction, - False is counterclockwise. - - Types accepted: - - - a Tuple of [float, bool], which all rotators will be set to - - - a list of Tuple[float, bool], mapping to the rotator indexes - in the hardware, i.e. [(0.5, False), (1.0, True)] will set - motor 0 to 50% speed going counterclockwise, motor 1 to 100% - speed going clockwise. - - - a dict of int to Tuple[float, bool], mapping rotator indexes - in the hardware, i.e. { 0: (0.5, False), 1: (1.0, True)} will - set motor 0 to 50% speed going counterclockwise, motor 1 to - 100% speed going clockwise. - - """ - if "RotateCmd" not in self.allowed_messages.keys(): - raise ButtplugDeviceError("RotateCmd not supported by device") - rotations_obj = [] - if isinstance(rotations, tuple): - rotations_obj = [RotateSubcommand(0, rotations[0], rotations[1])] - elif isinstance(rotations, list): - rotations_obj = [RotateSubcommand(x, rot[0], rot[1]) - for x, rot in enumerate(rotations)] - elif isinstance(rotations, dict): - rotations_obj = [RotateSubcommand(x, rot[0], rot[1]) - for x, rot in rotations.items()] - - msg = RotateCmd(self._index, - rotations_obj) - await self._client._send_message_expect_ok(msg) - - async def send_linear_cmd(self, linear: Union[Tuple[int, float], - List[Tuple[int, float]], - Dict[int, Tuple[int, float]]]): - """Tell the server to make a device stroke (move linearly) at a certain speed. - Use StopDeviceCmd to stop the device from moving. - - Args: - linear (Union[Tuple[int, float], List[Tuple[int, float]], Dict[int, Tuple[int, float]]]): - - Linear position(s) and movement duration(s), to set the - hardware to, assuming the hardware supports linear movement. - Position range is from 0.0 <= x <= 1.0. Duration is in - milliseconds, 1000ms = 1s. - - Types accepted: - - - a Tuple of [int, float], which all linear hardware is set to. - - - a list of Tuple[int, float], mapping to the linear indexes in - the hardware, i.e. [(1000, 0.9), (500, 0.1)] will set linear - movement 0 to 90% position and move to it over 1s, while - linear movement 1 will move to 10% position over 0.5s - - - a dict of Tuple[int, float], mapping to the linear indexes in - the hardware, i.e. {0: (1000, 0.9), 1: (500, 0.1)} will set - linear movement 0 to 90% position and move to it over 1s, - while linear movement 1 will move to 10% position over 0.5s - - """ - if "LinearCmd" not in self.allowed_messages.keys(): - raise ButtplugDeviceError("LinearCmd not supported by device") - linear_obj = [] - if isinstance(linear, tuple): - linear_obj = [LinearSubcommand(0, linear[0], linear[1])] - elif isinstance(linear, list): - linear_obj = [LinearSubcommand(x, l[0], l[1]) - for x, l in enumerate(linear)] - elif isinstance(linear, dict): - linear_obj = [LinearSubcommand(x, l[0], l[1]) - for x, l in linear.items()] - - msg = LinearCmd(self._index, - linear_obj) - await self._client._send_message_expect_ok(msg) - - async def send_stop_device_cmd(self): - """Tell the server to stop whatever device movements may be happening. - """ - await self._client._send_message_expect_ok(StopDeviceCmd(self._index)) diff --git a/buttplug/client/connector.py b/buttplug/client/connector.py deleted file mode 100644 index bcc0fc3..0000000 --- a/buttplug/client/connector.py +++ /dev/null @@ -1,52 +0,0 @@ -from abc import abstractmethod -from ..core.messages import ButtplugMessage -from typing import List -from ..core.errors import ButtplugError - - -class ButtplugClientConnectorError(ButtplugError): - """Raised when connector has connection issues. - - Attributes: - - message (str): Describes the nature of the exception - """ - pass - - -class ButtplugClientConnectorObserver(object): - @abstractmethod - async def handle_message(self, msg: ButtplugMessage): - pass - - -class ButtplugClientConnector(object): - def __init__(self): - self._observers: List[ButtplugClientConnectorObserver] = list() - self._connected: bool = False - - @abstractmethod - async def connect(self): - pass - - @abstractmethod - async def disconnect(self): - pass - - @abstractmethod - async def send(self, msg: ButtplugMessage): - pass - - @property - def connected(self): - return self._connected - - def add_observer(self, obs: ButtplugClientConnectorObserver): - self._observers.append(obs) - - def remove_observer(self, obs: ButtplugClientConnectorObserver): - self._observers.remove(obs) - - async def _notify_observers(self, msg: ButtplugMessage): - for obs in self._observers: - await obs._handle_message(msg) diff --git a/buttplug/client/websocket_connector.py b/buttplug/client/websocket_connector.py deleted file mode 100644 index 07db738..0000000 --- a/buttplug/client/websocket_connector.py +++ /dev/null @@ -1,47 +0,0 @@ -from .connector import ButtplugClientConnector, ButtplugClientConnectorError -from ..core.messages import ButtplugMessage -import websockets -import asyncio -import json -from typing import Optional - - -class ButtplugClientWebsocketConnector(ButtplugClientConnector): - - def __init__(self, addr: str): - super().__init__() - self.addr: str = addr - self.ws: Optional[websockets.WebSocketClientProtocol] - - async def connect(self): - try: - self.ws = await websockets.connect(self.addr) - except ConnectionRefusedError as e: - raise ButtplugClientConnectorError(e) - self._connected = True - asyncio.create_task(self._consumer_handler()) - - async def _consumer_handler(self): - # Guessing that this fails out once the websocket disconnects? - while True: - try: - message = await self.ws.recv() - except Exception as e: - print("Exiting read loop") - print(e) - break - msg_array = json.loads(message) - for msg in msg_array: - bp_msg = ButtplugMessage.from_dict(msg) - print(bp_msg) - await self._notify_observers(bp_msg) - - async def send(self, msg: ButtplugMessage): - msg_str = msg.as_json() - msg_str = "[" + msg_str + "]" - print(msg_str) - await self.ws.send(msg_str) - - async def disconnect(self): - await self.ws.close() - self._connected = False diff --git a/buttplug/core/__init__.py b/buttplug/core/__init__.py deleted file mode 100644 index e02e691..0000000 --- a/buttplug/core/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .errors import (ButtplugError, ButtplugDeviceError, - ButtplugHandshakeError, - ButtplugMessageError, ButtplugPingError, - ButtplugUnknownError) -from .messages import (ButtplugMessage, Ok, Error, Test, Log, - RequestServerInfo, ServerInfo, StartScanning, - StopScanning, DeviceAdded, MessageAttributes, - DeviceList, DeviceRemoved, DeviceInfo, RequestLog, - ScanningFinished, VibrateCmd, SpeedSubcommand, - RotateCmd, LinearCmd, RotateSubcommand, - LinearSubcommand, RequestDeviceList, StopDeviceCmd) -from .enums import ButtplugErrorCode, ButtplugLogLevel diff --git a/buttplug/core/enums.py b/buttplug/core/enums.py deleted file mode 100644 index 2ff109a..0000000 --- a/buttplug/core/enums.py +++ /dev/null @@ -1,19 +0,0 @@ -from enum import IntEnum - - -class ButtplugErrorCode(IntEnum): - ERROR_UNKNOWN = 0 - ERROR_INIT = 1 - ERROR_PING = 2 - ERROR_MSG = 3 - ERROR_DEVICE = 4 - - -class ButtplugLogLevel(object): - off: str = "Off" - fatal: str = "Fatal" - error: str = "Error" - warn: str = "Warn" - info: str = "Info" - debug: str = "Debug" - trace: str = "Trace" diff --git a/buttplug/core/errors.py b/buttplug/core/errors.py deleted file mode 100644 index c38c286..0000000 --- a/buttplug/core/errors.py +++ /dev/null @@ -1,60 +0,0 @@ -class ButtplugError(Exception): - """Base Class for Buttplug Errors. - - Attributes: - - message (str): Describes the nature of the exception - """ - def __init__(self, message: str): - self.message = message - - -class ButtplugHandshakeError(ButtplugError): - """Error thrown when errors happen during initial connection - - Attributes: - - message (str): Describes the nature of the exception - """ - pass - - -class ButtplugDeviceError(ButtplugError): - """Error thrown when errors happen during device operations, including - discovery, sending commands, etc. - - Attributes: - - message (str): Describes the nature of the exception - """ - pass - - -class ButtplugMessageError(ButtplugError): - """Error thrown when a message is incomplete or incorrectly formed. - - Attributes: - - message (str): Describes the nature of the exception - """ - pass - - -class ButtplugPingError(ButtplugError): - """Error thrown when a ping timeout request from the server is not met. - - Attributes: - - message (str): Describes the nature of the exception - """ - pass - - -class ButtplugUnknownError(ButtplugError): - """Unknown error, see message for more info. - - Attributes: - - message (str): Describes the nature of the exception - """ - pass diff --git a/buttplug/core/messages.py b/buttplug/core/messages.py deleted file mode 100644 index c8eb25f..0000000 --- a/buttplug/core/messages.py +++ /dev/null @@ -1,314 +0,0 @@ -# TODO Maybe use marshmallow? - -from dataclasses import dataclass -import json -import sys -from typing import Dict, List, Any -from .enums import ButtplugErrorCode - -class ButtplugMessageEncoder(json.JSONEncoder): - """Used for serializing ButtplugMessage types into Buttplug protocol JSON line - format. - - """ - def pascal_case(self, cc_string): - return ''.join(x.title() for x in cc_string.split('_')) - - def build_obj_dict(self, obj): - # Build camel case versions of our internal variables - return dict((self.pascal_case(key), value) - for (key, value) in obj.__dict__.items()) - - def default(self, obj): - # Helper classes should drop their names - if isinstance(obj, (MessageAttributes, DeviceInfo, - SpeedSubcommand, LinearSubcommand, - RotateSubcommand)): - return self.build_obj_dict(obj) - return {type(obj).__name__: self.build_obj_dict(obj)} - - -# ButtplugMessage isn't a dataclass, because we usually set id later than -# message construction, and don't want to require it in constructors -class ButtplugMessage(object): - SYSTEM_ID = 0 - DEFAULT_ID = 1 - - def __init__(self): - self.id = ButtplugMessage.DEFAULT_ID - - def as_json(self): - return ButtplugMessageEncoder().encode(self) - - @staticmethod - def from_json(json_str: str): - d = json.loads(json_str) - return ButtplugMessage.from_dict(d) - - @staticmethod - def from_dict(msg_dict: dict): - classname = list(msg_dict.keys())[0] - cls = getattr(sys.modules[__name__], classname) - d = list(msg_dict.values())[0] - msg = cls.from_dict(d) - msg.id = d["Id"] - return msg - - -@dataclass -class ButtplugDeviceMessage(ButtplugMessage): - device_index: int - - -class ButtplugOutgoingOnlyMessage(object): - pass - - -@dataclass -class Ok(ButtplugOutgoingOnlyMessage, ButtplugMessage): - @staticmethod - def from_dict(d: dict) -> "Ok": - return Ok() - - -@dataclass -class Error(ButtplugOutgoingOnlyMessage, ButtplugMessage): - error_message: str - error_code: int - - @staticmethod - def from_dict(d: dict) -> "Error": - return Error(d['ErrorMessage'], d['ErrorCode']) - - -@dataclass -class Test(ButtplugMessage): - test_string: str - - @staticmethod - def from_dict(d: dict) -> "Test": - return Test(d['TestString']) - - -@dataclass -class RequestServerInfo(ButtplugMessage): - client_name: str - message_version: int = 1 - - @staticmethod - def from_dict(d: dict) -> "RequestServerInfo": - return RequestServerInfo(d['ClientName'], d['MessageVersion']) - - -@dataclass -class ServerInfo(ButtplugMessage): - server_name: str - major_version: int - minor_version: int - build_version: int - message_version: int = 1 - max_ping_time: int = 0 - - @staticmethod - def from_dict(d: dict) -> "ServerInfo": - return ServerInfo(d['ServerName'], d['MajorVersion'], - d['MinorVersion'], d['BuildVersion'], - d['MessageVersion'], d['MaxPingTime']) - - -@dataclass -class RequestDeviceList(ButtplugMessage): - pass - - -class MessageAttributes: - def __init__(self, count: int = None): - if count is not None: - self.feature_count = count - - @staticmethod - def from_dict(d: dict) -> "MessageAttributes": - return MessageAttributes(d["FeatureCount"]) - - -@dataclass -class DeviceInfo: - device_name: str - device_index: int - # TODO Make this use MessageAttributes, currently just a dict because serialization was broken. - device_messages: Dict[str, Dict[str, Any]] - - @staticmethod - def from_dict(d: dict) -> "DeviceInfo": - attrs = dict([(k, v) for k, v in d["DeviceMessages"].items()]) - return DeviceInfo(d["DeviceName"], d["DeviceIndex"], attrs) - - -@dataclass -class DeviceList(ButtplugMessage, ButtplugOutgoingOnlyMessage): - devices: List[DeviceInfo] - - @staticmethod - def from_dict(d: dict) -> "DeviceList": - return DeviceList([DeviceInfo(x["DeviceName"], - x["DeviceIndex"], - x["DeviceMessages"]) - for x in d["Devices"]]) - - -# TODO Make this just be a DeviceInfo, currently own class because serialization was broken. -@dataclass -class DeviceAdded(ButtplugMessage, ButtplugOutgoingOnlyMessage): - device_name: str - device_index: int - # TODO Make this use MessageAttributes, currently just a dict because serialization was broken. - device_messages: Dict[str, Dict[str, Any]] - - @staticmethod - def from_dict(d: dict) -> "DeviceAdded": - attrs = dict([(k, v) for k, v in d["DeviceMessages"].items()]) - return DeviceAdded(d["DeviceName"], d["DeviceIndex"], attrs) - - -@dataclass -class DeviceRemoved(ButtplugMessage, ButtplugOutgoingOnlyMessage): - device_index: int - - @staticmethod - def from_dict(d: dict) -> "DeviceRemoved": - return DeviceRemoved(d["DeviceIndex"]) - - -@dataclass -class StartScanning(ButtplugMessage): - @staticmethod - def from_dict(d: dict) -> "StartScanning": - return StartScanning() - - -@dataclass -class StopScanning(ButtplugMessage): - @staticmethod - def from_dict(d: dict) -> "StopScanning": - return StopScanning() - - -@dataclass -class ScanningFinished(ButtplugMessage, ButtplugOutgoingOnlyMessage): - @staticmethod - def from_dict(d: dict) -> "ScanningFinished": - return ScanningFinished() - - -@dataclass -class RequestLog(ButtplugMessage): - log_level: str - - @staticmethod - def from_dict(d: dict) -> "RequestLog": - return RequestLog(d["LogLevel"]) - - -@dataclass -class Log(ButtplugMessage, ButtplugOutgoingOnlyMessage): - log_level: str - log_message: str - - @staticmethod - def from_dict(d: dict) -> "Log": - return Log(d["LogLevel"], d["LogMessage"]) - - -@dataclass -class Ping(ButtplugMessage): - @staticmethod - def from_dict(d: dict) -> "Ping": - return Ping() - - -@dataclass -class FleshlightLaunchFW12Cmd(ButtplugDeviceMessage): - position: int - speed: int - - -@dataclass -class LovenseCmd(ButtplugDeviceMessage): - command: str - - -@dataclass -class KiirooCmd(ButtplugDeviceMessage): - command: str - - -@dataclass -class VorzeA10CycloneCmd(ButtplugMessage): - speed: int - clockwise: bool - - -@dataclass -class SpeedSubcommand: - index: int - speed: float - - -@dataclass -class VibrateCmd(ButtplugDeviceMessage): - speeds: List[SpeedSubcommand] - - @staticmethod - def from_dict(d: dict) -> "VibrateCmd": - speeds = [] - for cmd in d["Speeds"]: - speeds.append(SpeedSubcommand(cmd["Index"], cmd["Speed"])) - return VibrateCmd(d["DeviceIndex"], speeds) - - -@dataclass -class RotateSubcommand: - index: int - speed: float - clockwise: bool - - -@dataclass -class RotateCmd(ButtplugDeviceMessage): - rotations: List[RotateSubcommand] - - @staticmethod - def from_dict(d: dict) -> "RotateCmd": - rotations = [] - for cmd in d["Rotations"]: - rotations.append(RotateSubcommand(cmd["Index"], cmd["Speed"], - cmd["Clockwise"])) - return RotateCmd(d["DeviceIndex"], rotations) - - -@dataclass -class LinearSubcommand: - index: int - duration: int - position: float - - -@dataclass -class LinearCmd(ButtplugDeviceMessage): - vectors: List[LinearSubcommand] - - @staticmethod - def from_dict(d: dict) -> "LinearCmd": - vectors = [] - for cmd in d["Vectors"]: - vectors.append(LinearSubcommand(cmd["Index"], cmd["Duration"], - cmd["Position"])) - return LinearCmd(d["DeviceIndex"], vectors) - - -class StopDeviceCmd(ButtplugDeviceMessage): - pass - - -class StopAllDevices(ButtplugMessage): - pass diff --git a/buttplug/utils/__init__.py b/buttplug/utils/__init__.py deleted file mode 100644 index 172c11b..0000000 --- a/buttplug/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .eventhandler import EventHandler - -__all__ = ["EventHandler"] diff --git a/buttplug/utils/eventhandler.py b/buttplug/utils/eventhandler.py deleted file mode 100644 index af7fa42..0000000 --- a/buttplug/utils/eventhandler.py +++ /dev/null @@ -1,55 +0,0 @@ -# Taken from https://bitbucket.org/marcusva/python-utils/ -# -# Original license is public domain, don't want to bring the whole package in, -# and it's not really updated anyways. - - -class EventHandler(object): - """A simple event handling class, which manages callbacks to be - executed. - """ - def __init__(self, sender): - self.callbacks = [] - self.sender = sender - - def __call__(self, *args): - """Executes all callbacks. - - Executes all connected callbacks in the order of addition, - passing the sender of the EventHandler as first argument and the - optional args as second, third, ... argument to them. - """ - return [callback(self.sender, *args) for callback in self.callbacks] - - def __iadd__(self, callback): - """Adds a callback to the EventHandler.""" - self.add(callback) - return self - - def __isub__(self, callback): - """Removes a callback from the EventHandler.""" - self.remove(callback) - return self - - def __len__(self): - """Gets the amount of callbacks connected to the EventHandler.""" - return len(self.callbacks) - - def __getitem__(self, index): - return self.callbacks[index] - - def __setitem__(self, index, value): - self.callbacks[index] = value - - def __delitem__(self, index): - del self.callbacks[index] - - def add(self, callback): - """Adds a callback to the EventHandler.""" - if not callable(callback): - raise TypeError("callback must be callable") - self.callbacks.append(callback) - - def remove(self, callback): - """Removes a callback from the EventHandler.""" - self.callbacks.remove(callback) diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/js/matomo.js b/docs/_static/js/matomo.js deleted file mode 100644 index 5d5ad92..0000000 --- a/docs/_static/js/matomo.js +++ /dev/null @@ -1,12 +0,0 @@ -var _paq = window._paq || []; -/* tracker methods like "setCustomDimension" should be called before "trackPageView" */ -_paq.push(["setCookieDomain", "*.buttplug-py.docs.buttplug.io"]); -_paq.push(['trackPageView']); -_paq.push(['enableLinkTracking']); -(function() { - var u="https://matomo.nonpolynomial.com/"; - _paq.push(['setTrackerUrl', u+'matomo.php']); - _paq.push(['setSiteId', '15']); - var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; - g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); -})(); diff --git a/docs/client.rst b/docs/client.rst deleted file mode 100644 index 2c96520..0000000 --- a/docs/client.rst +++ /dev/null @@ -1,5 +0,0 @@ -Client -====== - -.. autoclass:: buttplug.client.ButtplugClient - :members: diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 3aa53c8..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,64 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import os -import sys -sys.path.insert(0, os.path.abspath('..')) -from buttplug import __version__ - -# -- Project information ----------------------------------------------------- - -project = 'buttplug-py' -copyright = '2019, Nonpolynomial' -author = 'Nonpolynomial' - -# The full version, including alpha/beta/rc tags -release = __version__ - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.napoleon", - "m2r", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'pyramid' - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] -html_js_files = [ - 'js/matomo.js', -] - -html_sidebars = {'**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html']} -source_suffix = ['.rst', '.md'] diff --git a/docs/device.rst b/docs/device.rst deleted file mode 100644 index 261d96d..0000000 --- a/docs/device.rst +++ /dev/null @@ -1,5 +0,0 @@ -Device -====== - -.. autoclass:: buttplug.client.ButtplugClientDevice - :members: diff --git a/docs/enums.rst b/docs/enums.rst deleted file mode 100644 index 03b1370..0000000 --- a/docs/enums.rst +++ /dev/null @@ -1,8 +0,0 @@ -Enums -===== - -.. autoclass:: buttplug.core.ButtplugErrorCode - :members: - -.. autoclass:: buttplug.core.ButtplugLogLevel - :members: diff --git a/docs/errors.rst b/docs/errors.rst deleted file mode 100644 index 5878ef3..0000000 --- a/docs/errors.rst +++ /dev/null @@ -1,8 +0,0 @@ -Errors -====== - -.. automodule:: buttplug.core.errors - :members: - -.. autoclass:: buttplug.client.ButtplugClientConnectorError - :members: diff --git a/docs/eventhandler.rst b/docs/eventhandler.rst deleted file mode 100644 index 86821e7..0000000 --- a/docs/eventhandler.rst +++ /dev/null @@ -1,5 +0,0 @@ -Event Handler -============= - -.. autoclass:: buttplug.utils.EventHandler - :members: diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 496a108..0000000 --- a/docs/index.md +++ /dev/null @@ -1,52 +0,0 @@ -## Introduction - -buttplug-py is a Python 3 implementation of the client portion of the -Buttplug Intimate Hardware Protocol. For more information on the -protocol, see the project website at - -https://buttplug.io - -You may also be interested in the Buttplug Spec at - -https://buttplug-spec.docs.buttplug.io - -and the Buttplug Developer Guide, at - -https://buttplug-developer-guide.docs.buttplug.io - -## What Client Only Means - -buttplug-py is only an implementation of the client side of the -Buttplug Protocol. Programs written with this client cannot directly -access hardware, and will be required to connect to a Buttplug Server, -such as Intiface Desktop in order to access hardware. You can find -more information on Intiface Desktop at - -https://intiface.com/desktop - -## Python Notes - -Before discussing the basics of using buttplug-py, we'll cover a few -things to consider when implementing applications with it. - -- buttplug-py is HEAVILY Python 3.7. asyncio, dataclasses, typings, - all that fun stuff. I (qDot, the author) have no plans on - backporting, because I love these features and just plain don't - wanna. -- If someone else wants to backport for < 3.7 (but still >= 3 because - come on 2.7 EOLs in like 3 months), please feel free to get in - touch. I'm just not gonna do it myself. -- At the moment, only the Client and ClientDevice classes are - documented and meant to be used. Most of the protocol messages are - available in code, but if you go that direction, you're on your own. -- In order to make it look similar to the other implementations of the - Buttplug protocol (such as - [C#](https://github.com/buttplugio/buttplug-cshar) and - [Typescript/Javascript](https://github.com/buttplugio/buttplug-js)), - there is a faux-event system in buttplug-py. It's basically a way to - attach callbacks to a list to be called at a certain time. Examples - of this will be shown in the Usage section below. - -Event Handling looks similar to C#, with the ability to use the +=/-= -operators on EventHandler types to add/remove handlers. See the -example code below for demonstration of how event hookup works. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 22244f6..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,31 +0,0 @@ -buttplug-py: Buttplug Protocol Client for Python >= 3.7 -======================================================= - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - Home - errors - enums - client - device - eventhandler - - -.. mdinclude:: ./index.md - - -Code Example -============ - -Also `available on github `_ - -.. literalinclude:: ../examples/example.py - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 2119f51..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 4db98f1..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -m2r==0.2.1 -recommonmark==0.6.0 -Sphinx==2.2.0 diff --git a/examples/application.py b/examples/application.py new file mode 100644 index 0000000..4bf8f23 --- /dev/null +++ b/examples/application.py @@ -0,0 +1,176 @@ +"""Buttplug Python - Complete Application Example + +This is a complete, working example that demonstrates the full workflow +of a Buttplug application. If you're new to Buttplug, start here! + +Prerequisites: +1. Install Intiface Central: https://intiface.com/central +2. Start the server in Intiface Central (click "Start Server") +3. Run: python application.py +""" + +import asyncio + +from buttplug import ButtplugClient, DeviceOutputCommand, InputType, OutputType +from buttplug.errors import ButtplugDeviceError, ButtplugError + + +def print_device_capabilities(device) -> None: + """Print the capabilities of a device.""" + print(f" {device.name}") + + # Check output capabilities (things we can make the device do) + outputs = [] + if device.has_output(OutputType.VIBRATE): + outputs.append("Vibrate") + if device.has_output(OutputType.ROTATE): + outputs.append("Rotate") + if device.has_output(OutputType.OSCILLATE): + outputs.append("Oscillate") + if device.has_output(OutputType.POSITION) or device.has_output( + OutputType.POSITION_WITH_DURATION + ): + outputs.append("Position") + if device.has_output(OutputType.CONSTRICT): + outputs.append("Constrict") + + if outputs: + print(f" Outputs: {', '.join(outputs)}") + + # Check input capabilities (sensors we can read) + inputs = [] + if device.has_input(InputType.BATTERY): + inputs.append("Battery") + if device.has_input(InputType.RSSI): + inputs.append("RSSI") + + if inputs: + print(f" Inputs: {', '.join(inputs)}") + + print() + + +async def main() -> None: + print("===========================================") + print(" Buttplug Python Application Example") + print("===========================================\n") + + # Step 1: Create a client + # The client name identifies your application to the server. + client = ButtplugClient("My Buttplug Application") + + # Step 2: Set up event handlers + # Always do this BEFORE connecting to avoid missing events. + client.on_device_added = lambda d: print(f"[+] Device connected: {d.name}") + client.on_device_removed = lambda d: print(f"[-] Device disconnected: {d.name}") + client.on_disconnect = lambda: print("[!] Server connection lost!") + + # Step 3: Connect to the server + print("Connecting to Intiface Central...") + try: + await client.connect("ws://127.0.0.1:12345") + except ButtplugError as e: + print("ERROR: Could not connect to Intiface Central!") + print("Make sure Intiface Central is running and the server is started.") + print("Default address: ws://127.0.0.1:12345") + print(f"Error: {e}") + return + print("Connected!\n") + + # Step 4: Scan for devices + print("Scanning for devices...") + print("Turn on your Bluetooth/USB devices now.\n") + await client.start_scanning() + + # Wait for devices (in a real app, you might use a UI or timeout) + input("Press Enter when your devices are connected...") + await client.stop_scanning() + + # Step 5: Check what devices we found + devices = list(client.devices.values()) + if not devices: + print("No devices found. Make sure your device is:") + print(" - Turned on") + print(" - In pairing/discoverable mode") + print(" - Supported by Buttplug (check https://iostindex.com)") + await client.disconnect() + return + + print(f"\nFound {len(devices)} device(s):\n") + + # Step 6: Display device capabilities + for device in devices: + print_device_capabilities(device) + + # Step 7: Interactive device control + print("=== Interactive Control ===") + print("Commands:") + print(" v <0-100> - Vibrate all devices at percentage") + print(" s - Stop all devices") + print(" b - Read battery levels") + print(" q - Quit\n") + + while True: + try: + user_input = input("> ").strip().lower() + except EOFError: + break + + if not user_input: + continue + + try: + if user_input.startswith("v "): + # Vibrate command + try: + percent = int(user_input[2:]) + if 0 <= percent <= 100: + intensity = percent / 100.0 + for device in devices: + if device.has_output(OutputType.VIBRATE): + await device.run_output( + DeviceOutputCommand(OutputType.VIBRATE, intensity) + ) + print(f" {device.name}: vibrating at {percent}%") + else: + print(" Usage: v <0-100>") + except ValueError: + print(" Usage: v <0-100>") + + elif user_input == "s": + # Stop all devices + await client.stop_all_devices() + print(" All devices stopped.") + + elif user_input == "b": + # Read battery levels + for device in devices: + if device.has_input(InputType.BATTERY): + try: + battery = await device.battery() + print(f" {device.name}: {battery * 100:.0f}% battery") + except ButtplugDeviceError as e: + print(f" {device.name}: could not read battery - {e}") + else: + print(f" {device.name}: no battery sensor") + + elif user_input == "q": + break + + else: + print(" Unknown command. Use v, s, b, or q.") + + except ButtplugDeviceError as e: + print(f" Device error: {e}") + except ButtplugError as e: + print(f" Error: {e}") + + # Step 8: Clean up + print("\nStopping devices and disconnecting...") + await client.stop_all_devices() + await client.disconnect() + print("Goodbye!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/connection.py b/examples/connection.py new file mode 100644 index 0000000..7e2fdf5 --- /dev/null +++ b/examples/connection.py @@ -0,0 +1,43 @@ +"""Connection - Connect to a Buttplug server. + +This is the simplest possible Buttplug example. It connects to a +Buttplug server (like Intiface Central) and shows connection status. + +Prerequisites: +1. Install Intiface Central: https://intiface.com/central/ +2. Start Intiface Central and click "Start Server" +3. Run this script: python connection.py +""" + +import asyncio + +from buttplug import ButtplugClient, ButtplugError + + +async def main() -> None: + # Create a client with your application's name + client = ButtplugClient("Connection Example") + + try: + # Connect to the server (Intiface Central default address) + print("Connecting to server...") + await client.connect("ws://127.0.0.1:12345") + print(f"Connected to: {client.server_name}") + + # Connection is established - you can now scan for devices + print("Connection successful!") + + except ButtplugError as e: + # Handle connection errors + print(f"Failed to connect: {e}") + return + + finally: + # Always disconnect when done + if client.connected: + await client.disconnect() + print("Disconnected.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/device_control.py b/examples/device_control.py new file mode 100644 index 0000000..a018f75 --- /dev/null +++ b/examples/device_control.py @@ -0,0 +1,96 @@ +"""Device Control - Vibrate, rotate, and position commands. + +This example shows how to control different types of devices: +- Vibrators: Set vibration intensity +- Rotators: Set rotation speed +- Strokers: Move to position over time + +The example checks what each device supports before sending commands, +so it will work with any device type. + +Prerequisites: +1. Install Intiface Central: https://intiface.com/central/ +2. Start Intiface Central and click "Start Server" +3. Have a supported device connected +4. Run this script: python device_control.py +""" + +import asyncio + +from buttplug import ButtplugClient, DeviceOutputCommand, OutputType + + +async def main() -> None: + client = ButtplugClient("Device Control Example") + + # Set up event handlers to see devices as they connect + client.on_device_added = lambda d: print(f"Device connected: {d.name}") + client.on_device_removed = lambda d: print(f"Device disconnected: {d.name}") + + print("Connecting to server...") + await client.connect("ws://127.0.0.1:12345") + + print("Scanning for devices (5 seconds)...") + await client.start_scanning() + await asyncio.sleep(5) + await client.stop_scanning() + + if not client.devices: + print("No devices found!") + await client.disconnect() + return + + # Control each device based on its capabilities + for device in client.devices.values(): + print(f"\nControlling: {device.name}") + + # Vibration + if device.has_output(OutputType.VIBRATE): + print(" Starting vibration at 25%...") + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.25)) + await asyncio.sleep(1) + + print(" Increasing to 50%...") + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) + await asyncio.sleep(1) + + print(" Full power (100%)...") + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 1.0)) + await asyncio.sleep(1) + + # Rotation + if device.has_output(OutputType.ROTATE): + print(" Rotating at 50%...") + await device.run_output(DeviceOutputCommand(OutputType.ROTATE, 0.5)) + await asyncio.sleep(2) + + # Position (strokers/linear devices) + if device.has_output(OutputType.POSITION_WITH_DURATION): + print(" Moving to top position...") + await device.run_output( + DeviceOutputCommand(OutputType.POSITION_WITH_DURATION, 1.0, duration=500) + ) + await asyncio.sleep(1) + + print(" Moving to bottom position...") + await device.run_output( + DeviceOutputCommand(OutputType.POSITION_WITH_DURATION, 0.0, duration=500) + ) + await asyncio.sleep(1) + + print(" Moving to middle...") + await device.run_output( + DeviceOutputCommand(OutputType.POSITION_WITH_DURATION, 0.5, duration=250) + ) + await asyncio.sleep(1) + + # Stop the device + print(" Stopping device...") + await device.stop() + + print("\nAll done!") + await client.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/device_enumeration.py b/examples/device_enumeration.py new file mode 100644 index 0000000..00c186a --- /dev/null +++ b/examples/device_enumeration.py @@ -0,0 +1,61 @@ +"""Device Enumeration - Scan for and list devices. + +This example shows how to scan for devices and handle device +connection/disconnection events. + +Prerequisites: +1. Install Intiface Central: https://intiface.com/central/ +2. Start Intiface Central and click "Start Server" +3. Have a supported device nearby and powered on +4. Run this script: python device_enumeration.py +""" + +import asyncio + +from buttplug import ButtplugClient, ButtplugDevice + + +def on_device_added(device: ButtplugDevice) -> None: + """Called when a device connects.""" + print(f"Device connected: {device.name} (index {device.index})") + + +def on_device_removed(device: ButtplugDevice) -> None: + """Called when a device disconnects.""" + print(f"Device disconnected: {device.name}") + + +async def main() -> None: + client = ButtplugClient("Device Enumeration Example") + + # Set up event handlers before connecting + client.on_device_added = on_device_added + client.on_device_removed = on_device_removed + + print("Connecting to server...") + await client.connect("ws://127.0.0.1:12345") + print(f"Connected to: {client.server_name}") + + # Start scanning for devices + print("\nScanning for devices (5 seconds)...") + await client.start_scanning() + await asyncio.sleep(5) + await client.stop_scanning() + + # List all discovered devices + if client.devices: + print(f"\nFound {len(client.devices)} device(s):") + for device in client.devices.values(): + print(f" - {device.name}") + if device.display_name: + print(f" Display name: {device.display_name}") + else: + print("\nNo devices found.") + print("Make sure your device is on and in pairing mode.") + + await client.disconnect() + print("\nDone!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/device_info.py b/examples/device_info.py new file mode 100644 index 0000000..09f321b --- /dev/null +++ b/examples/device_info.py @@ -0,0 +1,79 @@ +"""Device Info - Inspect device capabilities. + +This example shows how to inspect device features and capabilities: +- List all available features +- Check output types (vibrate, rotate, position) +- Check input types (battery, sensors) +- Access individual motors on multi-motor devices + +Prerequisites: +1. Install Intiface Central: https://intiface.com/central/ +2. Start Intiface Central and click "Start Server" +3. Have a supported device connected +4. Run this script: python device_info.py +""" + +import asyncio + +from buttplug import ButtplugClient, OutputType + + +async def main() -> None: + client = ButtplugClient("Device Info Example") + + print("Connecting to server...") + await client.connect("ws://127.0.0.1:12345") + + print("Scanning for devices (5 seconds)...") + await client.start_scanning() + await asyncio.sleep(5) + await client.stop_scanning() + + if not client.devices: + print("No devices found!") + await client.disconnect() + return + + # Inspect each device's features in detail + for device in client.devices.values(): + print(f"\n{'=' * 50}") + print(f"Device: {device.name}") + print(f"Index: {device.index}") + print(f"Display Name: {device.display_name or '(none)'}") + print(f"Timing Gap: {device.message_timing_gap}ms") + print(f"{'=' * 50}") + + # List all features + print(f"\nFeatures ({len(device.features)}):") + for feature in device.features.values(): + print(f"\n Feature {feature.index}: {feature.description or '(no description)'}") + + # Show outputs + if feature.outputs: + print(" Outputs:") + for output_type in feature.outputs: + value_range = feature.get_output_range(output_type) + duration_range = feature.get_output_duration_range(output_type) + print(f" - {output_type}: values {value_range}", end="") + if duration_range: + print(f", duration {duration_range}ms", end="") + print() + + # Show inputs + if feature.inputs: + print(" Inputs:") + for input_type, input_def in feature.inputs.items(): + print(f" - {input_type}: commands {input_def.command}") + + # Show multi-motor info + vibrate_features = device.get_features_with_output(OutputType.VIBRATE) + if len(vibrate_features) > 1: + print(f"\nThis device has {len(vibrate_features)} independent vibrators!") + print("Use device.send_output() to control them individually.") + + await client.disconnect() + print("\nDone!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/errors.py b/examples/errors.py new file mode 100644 index 0000000..a9e01da --- /dev/null +++ b/examples/errors.py @@ -0,0 +1,89 @@ +"""Error Handling - Handle errors gracefully. + +This example shows how to handle various error conditions: +- Connection failures +- Device communication errors +- Server disconnections + +Prerequisites: +1. Install Intiface Central: https://intiface.com/central/ +2. Run this script (server doesn't need to be running to see error handling) +""" + +import asyncio + +from buttplug import ( + ButtplugClient, + ButtplugConnectionError, + ButtplugDeviceError, + ButtplugError, + ButtplugHandshakeError, + ButtplugPingError, +) + + +async def main() -> None: + client = ButtplugClient("Error Handling Example") + + # Handle disconnection events + def on_disconnect() -> None: + print("Server disconnected unexpectedly!") + + client.on_disconnect = on_disconnect + + # Try to connect with error handling + try: + print("Attempting to connect to server...") + await client.connect("ws://127.0.0.1:12345") + print(f"Connected to: {client.server_name}") + + except ButtplugConnectionError as e: + # Server not running or network issue + print(f"Connection failed: {e}") + print("Is Intiface Central running?") + return + + except ButtplugHandshakeError as e: + # Server rejected the connection (version mismatch, etc.) + print(f"Handshake failed: {e}") + return + + except ButtplugError as e: + # Catch-all for other Buttplug errors + print(f"Unexpected error: {e}") + return + + # Scan and control devices with error handling + try: + print("\nScanning for devices...") + await client.start_scanning() + await asyncio.sleep(3) + await client.stop_scanning() + + for device in client.devices.values(): + print(f"\nControlling: {device.name}") + try: + await device.vibrate(0.5) + await asyncio.sleep(1) + await device.stop() + print(" Control successful!") + + except ButtplugDeviceError as e: + # Device-specific error (disconnected, doesn't support command) + print(f" Device error: {e}") + + except ButtplugPingError: + # Server stopped responding + print("Server ping timeout - connection lost") + + except ButtplugError as e: + print(f"Error during operation: {e}") + + finally: + if client.connected: + await client.disconnect() + print("\nDisconnected cleanly.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/example.py b/examples/example.py deleted file mode 100644 index 7287b1e..0000000 --- a/examples/example.py +++ /dev/null @@ -1,175 +0,0 @@ -# buttplug-py example code -# -# Buttplug Clients are fairly simple things, in charge of the following -# tasks: -# -# - Connect to a Buttplug Server and Identify Itself -# - Enumerate Devices -# - Control Found Devices -# -# That's about it, really. -# -# This is a program that connects to a server, scans for devices, and runs -# commands on them when they are found. It'll be copiously commented so you -# have some idea of what's going on and can maybe make something yourself. -# -# NOTE: We'll be talking about this in terms of execution flow, so you'll want -# to start at the bottom and work your way up. - -# These are really the only things you actually need out of the library. The -# Client and ClientDevice classes wrap all of the functionality you'll need to -# talk to servers and access toys. -from buttplug.client import (ButtplugClientWebsocketConnector, ButtplugClient, - ButtplugClientDevice, ButtplugClientConnectorError) -from buttplug.core import ButtplugLogLevel -import asyncio - - -async def cancel_me(): - print('cancel_me(): before sleep') - - try: - await asyncio.sleep(3600) - except asyncio.CancelledError: - pass - - -async def device_added_task(dev: ButtplugClientDevice): - # Ok, so we got a new device in! Neat! - # - # First off, we'll print the name of the devices. - - print("Device Added: {}".format(dev.name)) - - # Once we've done that, we can send some commands to the device, depending - # on what it can do. As of the current version I'm writing this for - # (v0.0.3), all the client can send to devices are generic messages. - # Specifically: - # - # - VibrateCmd - # - RotateCmd - # - LinearCmd - # - # However, this is good enough to still do a lot of stuff. - # - # These capabilities are held in the "messages" member of the - # ButtplugClientDevice. - - if "VibrateCmd" in dev.allowed_messages.keys(): - # If we see that "VibrateCmd" is an allowed message, it means the - # device can vibrate. We can call send_vibrate_cmd on the device and - # it'll tell the server to make the device start vibrating. - await dev.send_vibrate_cmd(0.5) - # We let it vibrate at 50% speed for 1 second, then we stop it. - await asyncio.sleep(1) - # We can use send_stop_device_cmd to stop the device from vibrating, as - # well as anything else it's doing. If the device was vibrating AND - # rotating, we could use send_vibrate_cmd(0) to just stop the - # vibration. - await dev.send_stop_device_cmd() - if "LinearCmd" in dev.allowed_messages.keys(): - # If we see that "LinearCmd" is an allowed message, it means the device - # can move back and forth. We can call send_linear_cmd on the device - # and it'll tell the server to make the device move to 90% of the - # maximum position over 1 second (1000ms). - await dev.send_linear_cmd((1000, 0.9)) - # We wait 1 second for the move, then we move it back to the 0% - # position. - await asyncio.sleep(1) - await dev.send_linear_cmd((1000, 0)) - - -def device_added(emitter, dev: ButtplugClientDevice): - asyncio.create_task(device_added_task(dev)) - -def device_removed(emitter, dev: ButtplugClientDevice): - print("Device removed: ", dev) - -async def main(): - # And now we're in the main function. - # - # First, we'll need to set up a client object. This is our conduit to the - # server. - # - # We create a Client object, passing it the name we want for the client. - # Names are shown in things like the Intiface Desktop Server GUI. - - client = ButtplugClient("Test Client") - - # Now we have a client called "Test Client", but it's not connected to - # anything yet. We can fix that by creating a connector. Connectors - # allow clients to talk to servers through different methods, including: - # - # - Websockets - # - IPC (Not currently available in Python) - # - WebRTC (Not currently available in Python) - # - TCP/UDP (Not currently available in Python) - # - # For now, all we've implemented in python is a Websocket connector, so - # we'll use that. - - connector = ButtplugClientWebsocketConnector("ws://127.0.0.1:12345") - - # This connector will connect to Intiface Desktop on the local machine, - # using the default port for insecure websockets. - # - # There's one more step before we connect to a client, and that's - # setting up an event handler. - - client.device_added_handler += device_added - client.device_removed_handler += device_removed - - # Whenever we connect to a client, we'll instantly get a list of devices - # already connected (yes, this sometimes happens, mostly due to windows - # weirdness). We'll want to make sure we know about those. - # - # Finally, we connect. - - try: - await client.connect(connector) - except ButtplugClientConnectorError as e: - print("Could not connect to server, exiting: {}".format(e.message)) - return - - # If this succeeds, we'll be connected. If not, we'll probably have some - # sort of exception thrown of type ButtplugClientConnectorException - # - # Let's receive log messages, since they're a handy way to find out what - # the server is doing. We can choose the level from the ButtplugLogLevel - # object. - - # await client.request_log(ButtplugLogLevel.info) - - # Now we move on to looking for devices. - - await client.start_scanning() - - # This will tell the server to start scanning for devices, and returns - # while it's scanning. If we get any new devices, the device_added_task - # function that we assigned as an event handler earlier will be called. - # - # Since everything interesting happens after devices have connected, now - # all we have to do here is wait. So we do, asynchronously, so other things - # can continue running. Now that you've made it this far, go look at what - # the device_added_task does. - - task = asyncio.create_task(cancel_me()) - try: - await task - except asyncio.CancelledError: - pass - - # Ok so someone hit Ctrl-C or something and we've broken out of our task - # wait. Let's tell the server to stop scanning. - await client.stop_scanning() - - # Now that we've done that, we just disconnect and we're done! - await client.disconnect() - print("Disconnected, quitting") - -# Here we are. The beginning. We'll spin up an asyncio event loop that runs the -# main function. Remember that if you don't want to make your whole program -# async (because, for instance, it's already written in a non-async way), you -# can always create a thread for the asyncio loop to run in, and do some sort -# of communication in/out of that thread to the rest of your program. -asyncio.run(main(), debug=True) diff --git a/examples/sensors.py b/examples/sensors.py new file mode 100644 index 0000000..d9b06f6 --- /dev/null +++ b/examples/sensors.py @@ -0,0 +1,76 @@ +"""Sensors - Read battery level and signal strength. + +This example shows how to read sensor data from devices: +- Battery level (most Bluetooth devices) +- RSSI (Bluetooth signal strength) + +Not all devices have sensors. The example checks what each device +supports before trying to read. + +Prerequisites: +1. Install Intiface Central: https://intiface.com/central/ +2. Start Intiface Central and click "Start Server" +3. Have a supported device connected +4. Run this script: python sensors.py +""" + +import asyncio + +from buttplug import ButtplugClient, InputType + + +async def main() -> None: + client = ButtplugClient("Sensor Reading Example") + + print("Connecting to server...") + await client.connect("ws://127.0.0.1:12345") + + print("Scanning for devices (5 seconds)...") + await client.start_scanning() + await asyncio.sleep(5) + await client.stop_scanning() + + if not client.devices: + print("No devices found!") + await client.disconnect() + return + + # Read sensors from each device + for device in client.devices.values(): + print(f"\n{device.name}:") + + # Battery level + if device.has_input(InputType.BATTERY): + try: + battery = await device.battery() + print(f" Battery: {battery * 100:.0f}%") + except Exception as e: + print(f" Battery read failed: {e}") + else: + print(" No battery sensor") + + # Signal strength (RSSI) + if device.has_input(InputType.RSSI): + try: + rssi = await device.rssi() + # RSSI is typically -10 (excellent) to -100 (poor) dBm + if rssi > -50: + quality = "Excellent" + elif rssi > -70: + quality = "Good" + elif rssi > -80: + quality = "Fair" + else: + quality = "Poor" + print(f" Signal: {rssi} dBm ({quality})") + except Exception as e: + print(f" RSSI read failed: {e}") + else: + print(" No signal strength sensor") + + await client.disconnect() + print("\nDone!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..520fc42 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,78 @@ +[project] +name = "buttplug" +version = "1.0.0" +description = "Buttplug Intimate Hardware Control Library" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.10" +authors = [ + { name = "Nonpolynomial Labs", email = "kyle@nonpolynomial.com" } +] +keywords = ["buttplug", "haptics", "teledildonics", "hardware", "iot"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [ + "websockets>=12.0", + "pydantic>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-asyncio>=0.21", + "mypy>=1.0", + "ruff>=0.1", +] + +[project.urls] +Homepage = "https://buttplug.io" +Repository = "https://github.com/buttplugio/buttplug-py" +Documentation = "https://docs.buttplug.io" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/buttplug"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true +plugins = ["pydantic.mypy"] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[dependency-groups] +dev = [ + "mypy>=1.19.1", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.14", +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index a38bf92..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -python_classes = \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fe0ddac..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pkg-resources==0.0.0 -websockets==7.0 diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index 475ba51..0000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -3.7 diff --git a/setup.py b/setup.py deleted file mode 100644 index 285def9..0000000 --- a/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -from setuptools import setup, find_packages -from buttplug import __version__ - -# read the contents of your README file -from os import path -this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - -setup(name="buttplug", - version=__version__, - author="Nonpolynomial", - author_email="kyle@nonpolynomial.com", - description="Python implementation of the Buttplug Intimate Hardware Control Protocol.", - long_description=long_description, - long_description_content_type='text/markdown', - url="https://github.com/buttplugio/buttplug-py", - classifiers=[ - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Development Status :: 4 - Beta", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - ], - install_requires=['websockets>=7.0', ], - packages=find_packages(exclude=["docs", "*.tests", "*.tests.*", - "tests.*", "tests"])) diff --git a/src/buttplug/__init__.py b/src/buttplug/__init__.py new file mode 100644 index 0000000..b47141b --- /dev/null +++ b/src/buttplug/__init__.py @@ -0,0 +1,72 @@ +"""Buttplug - Intimate Hardware Control Library. + +A Python client library for the Buttplug protocol v4. + +Basic usage: + from buttplug import ButtplugClient, DeviceOutputCommand, OutputType + + async def main(): + client = ButtplugClient("My App") + await client.connect("ws://127.0.0.1:12345") + + await client.start_scanning() + await asyncio.sleep(5) + await client.stop_scanning() + + for device in client.devices.values(): + if device.has_output(OutputType.VIBRATE): + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) + await asyncio.sleep(1) + await device.stop() + + await client.disconnect() + + asyncio.run(main()) +""" + +__version__ = "1.0.0" + +# Client and Device (public API) +from buttplug.client import ButtplugClient +from buttplug.command import DeviceOutputCommand +from buttplug.device import ButtplugDevice +from buttplug.enums import ErrorCode, InputCommandType, InputType, OutputType + +# Errors (public API) +from buttplug.errors import ( + ButtplugConnectorError, + ButtplugDeviceError, + ButtplugError, + ButtplugHandshakeError, + ButtplugMessageError, + ButtplugPingError, + ButtplugUnknownError, +) + +# Feature-level control (advanced API) +from buttplug.feature import CommandValue, DeviceFeature + +__all__ = [ + # Version + "__version__", + # Enums + "OutputType", + "InputType", + "InputCommandType", + "ErrorCode", + # Errors + "ButtplugError", + "ButtplugConnectorError", + "ButtplugHandshakeError", + "ButtplugPingError", + "ButtplugDeviceError", + "ButtplugMessageError", + "ButtplugUnknownError", + # Client and Device + "ButtplugClient", + "ButtplugDevice", + "DeviceOutputCommand", + # Feature-level control + "DeviceFeature", + "CommandValue", +] diff --git a/src/buttplug/_messages/__init__.py b/src/buttplug/_messages/__init__.py new file mode 100644 index 0000000..013edf5 --- /dev/null +++ b/src/buttplug/_messages/__init__.py @@ -0,0 +1,51 @@ +"""Internal message models for Buttplug protocol.""" + +from buttplug._messages.base import ButtplugMessage +from buttplug._messages.commands import ( + InputCmd, + InputReading, + OutputCmd, + StopCmd, +) +from buttplug._messages.device_info import ( + DeviceFeatureDefinition, + DeviceInfo, + DeviceList, + FeatureInputDefinition, + FeatureOutputDefinition, +) +from buttplug._messages.handshake import ( + Disconnect, + Error, + Ok, + Ping, + RequestDeviceList, + RequestServerInfo, + ScanningFinished, + ServerInfo, + StartScanning, + StopScanning, +) + +__all__ = [ + "ButtplugMessage", + "RequestServerInfo", + "ServerInfo", + "Ok", + "Error", + "Ping", + "Disconnect", + "StartScanning", + "StopScanning", + "ScanningFinished", + "RequestDeviceList", + "DeviceList", + "DeviceInfo", + "DeviceFeatureDefinition", + "FeatureInputDefinition", + "FeatureOutputDefinition", + "OutputCmd", + "InputCmd", + "InputReading", + "StopCmd", +] diff --git a/src/buttplug/_messages/base.py b/src/buttplug/_messages/base.py new file mode 100644 index 0000000..591b207 --- /dev/null +++ b/src/buttplug/_messages/base.py @@ -0,0 +1,99 @@ +"""Base message class and serialization utilities.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import BaseModel, ConfigDict, Field + + +class ButtplugMessage(BaseModel): + """Base class for all Buttplug protocol messages.""" + + model_config = ConfigDict( + populate_by_name=True, + extra="forbid", + ) + + # Message type name used in JSON (set by subclasses) + _message_type: ClassVar[str] = "" + + id: int = Field(alias="Id") + + def to_protocol(self) -> list[dict[str, Any]]: + """Serialize message to Buttplug protocol format.""" + return [{self._message_type: self.model_dump(by_alias=True, exclude_none=True)}] + + @classmethod + def get_message_type(cls) -> str: + """Get the protocol message type name.""" + return cls._message_type + + +def parse_message(data: dict[str, Any]) -> ButtplugMessage: + """Parse a single message from protocol format. + + Args: + data: A dict with a single key (message type) and value (message fields). + + Returns: + Parsed message object. + + Raises: + ValueError: If message type is unknown. + """ + from buttplug._messages.commands import ( + InputCmd, + InputReading, + OutputCmd, + StopCmd, + ) + from buttplug._messages.device_info import DeviceList + from buttplug._messages.handshake import ( + Disconnect, + Error, + Ok, + Ping, + RequestDeviceList, + RequestServerInfo, + ScanningFinished, + ServerInfo, + StartScanning, + StopScanning, + ) + + message_types: dict[str, type[ButtplugMessage]] = { + "RequestServerInfo": RequestServerInfo, + "ServerInfo": ServerInfo, + "Ok": Ok, + "Error": Error, + "Ping": Ping, + "Disconnect": Disconnect, + "StartScanning": StartScanning, + "StopScanning": StopScanning, + "ScanningFinished": ScanningFinished, + "RequestDeviceList": RequestDeviceList, + "DeviceList": DeviceList, + "OutputCmd": OutputCmd, + "InputCmd": InputCmd, + "InputReading": InputReading, + "StopCmd": StopCmd, + } + + if len(data) != 1: + msg = f"Expected single message type, got {len(data)}" + raise ValueError(msg) + + msg_type = next(iter(data.keys())) + msg_data = data[msg_type] + + if msg_type not in message_types: + msg = f"Unknown message type: {msg_type}" + raise ValueError(msg) + + return message_types[msg_type].model_validate(msg_data) + + +def parse_messages(data: list[dict[str, Any]]) -> list[ButtplugMessage]: + """Parse an array of messages from protocol format.""" + return [parse_message(msg) for msg in data] diff --git a/src/buttplug/_messages/commands.py b/src/buttplug/_messages/commands.py new file mode 100644 index 0000000..ba747c3 --- /dev/null +++ b/src/buttplug/_messages/commands.py @@ -0,0 +1,79 @@ +"""Device control command messages.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import BaseModel, ConfigDict, Field + +from buttplug._messages.base import ButtplugMessage + + +class OutputCommand(BaseModel): + """Base for output command payloads.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + value: int = Field(alias="Value") + + +class OutputCommandWithDuration(OutputCommand): + """Output command with duration (for position commands).""" + + duration: int = Field(alias="Duration") + + +class OutputCommandWithDirection(OutputCommand): + """Output command with direction (for rotate commands).""" + + clockwise: bool = Field(alias="Clockwise") + + +class OutputCmd(ButtplugMessage): + """Command to control device output (vibrate, rotate, position, etc.).""" + + _message_type: ClassVar[str] = "OutputCmd" + + device_index: int = Field(alias="DeviceIndex") + feature_index: int = Field(alias="FeatureIndex") + command: dict[str, Any] = Field(alias="Command") + + +class InputCmd(ButtplugMessage): + """Command to read or subscribe to device sensor.""" + + _message_type: ClassVar[str] = "InputCmd" + + device_index: int = Field(alias="DeviceIndex") + feature_index: int = Field(alias="FeatureIndex") + input_type: str = Field(alias="Type") + command: str = Field(alias="Command") + + +class InputReadingValue(BaseModel): + """Single input reading value.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + value: int = Field(alias="Value") + + +class InputReading(ButtplugMessage): + """Sensor reading from device.""" + + _message_type: ClassVar[str] = "InputReading" + + device_index: int = Field(alias="DeviceIndex") + feature_index: int = Field(alias="FeatureIndex") + reading: dict[str, InputReadingValue] = Field(alias="Reading") + + +class StopCmd(ButtplugMessage): + """Stop device outputs and/or unsubscribe from inputs.""" + + _message_type: ClassVar[str] = "StopCmd" + + device_index: int | None = Field(default=None, alias="DeviceIndex") + feature_index: int | None = Field(default=None, alias="FeatureIndex") + inputs: bool = Field(default=True, alias="Inputs") + outputs: bool = Field(default=True, alias="Outputs") diff --git a/src/buttplug/_messages/device_info.py b/src/buttplug/_messages/device_info.py new file mode 100644 index 0000000..02b528e --- /dev/null +++ b/src/buttplug/_messages/device_info.py @@ -0,0 +1,94 @@ +"""Device information and enumeration messages.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from buttplug._messages.base import ButtplugMessage + + +class FeatureOutputDefinition(BaseModel): + """Output capability definition for a feature.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + value: tuple[int, int] = Field(alias="Value") + duration: tuple[int, int] | None = Field(default=None, alias="Duration") + + +class FeatureInputDefinition(BaseModel): + """Input capability definition for a feature.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + value: list[tuple[int, int]] = Field(alias="Value") + command: list[str] = Field(alias="Command") + + +class DeviceFeatureDefinition(BaseModel): + """Definition of a single device feature.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + feature_index: int = Field(alias="FeatureIndex") + feature_description: str | None = Field(default=None, alias="FeatureDescription") + output: dict[str, FeatureOutputDefinition] | None = Field(default=None, alias="Output") + input: dict[str, FeatureInputDefinition] | None = Field(default=None, alias="Input") + + +class DeviceInfo(BaseModel): + """Information about a single device.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + device_name: str = Field(alias="DeviceName") + device_index: int = Field(alias="DeviceIndex") + device_message_timing_gap: int = Field(default=0, alias="DeviceMessageTimingGap") + device_display_name: str | None = Field(default=None, alias="DeviceDisplayName") + device_features: dict[int, DeviceFeatureDefinition] = Field( + default_factory=dict, alias="DeviceFeatures" + ) + + @model_validator(mode="before") + @classmethod + def parse_features_dict(cls, data: Any) -> Any: + """Convert string-keyed DeviceFeatures dict to int-keyed.""" + if isinstance(data, dict): + features = data.get("DeviceFeatures") or data.get("device_features") + if features and isinstance(features, dict): + # Protocol uses string keys like "0", "1", etc. + converted = {} + for key, value in features.items(): + converted[int(key)] = value + if "DeviceFeatures" in data: + data["DeviceFeatures"] = converted + else: + data["device_features"] = converted + return data + + +class DeviceList(ButtplugMessage): + """List of connected devices.""" + + _message_type: ClassVar[str] = "DeviceList" + + devices: dict[int, DeviceInfo] = Field(default_factory=dict, alias="Devices") + + @model_validator(mode="before") + @classmethod + def parse_devices_dict(cls, data: Any) -> Any: + """Convert string-keyed Devices dict to int-keyed.""" + if isinstance(data, dict): + devices = data.get("Devices") or data.get("devices") + if devices and isinstance(devices, dict): + # Protocol uses string keys like "0", "1", etc. + converted = {} + for key, value in devices.items(): + converted[int(key)] = value + if "Devices" in data: + data["Devices"] = converted + else: + data["devices"] = converted + return data diff --git a/src/buttplug/_messages/handshake.py b/src/buttplug/_messages/handshake.py new file mode 100644 index 0000000..e87e670 --- /dev/null +++ b/src/buttplug/_messages/handshake.py @@ -0,0 +1,82 @@ +"""Handshake, status, and scanning messages.""" + +from __future__ import annotations + +from typing import ClassVar + +from pydantic import Field + +from buttplug._messages.base import ButtplugMessage +from buttplug.enums import ErrorCode + + +class RequestServerInfo(ButtplugMessage): + """Client identification message sent at connection start.""" + + _message_type: ClassVar[str] = "RequestServerInfo" + + client_name: str = Field(alias="ClientName") + protocol_version_major: int = Field(default=4, alias="ProtocolVersionMajor") + protocol_version_minor: int = Field(default=0, alias="ProtocolVersionMinor") + + +class ServerInfo(ButtplugMessage): + """Server identification response.""" + + _message_type: ClassVar[str] = "ServerInfo" + + server_name: str | None = Field(default=None, alias="ServerName") + max_ping_time: int = Field(alias="MaxPingTime") + protocol_version_major: int = Field(alias="ProtocolVersionMajor") + protocol_version_minor: int = Field(alias="ProtocolVersionMinor") + + +class Ok(ButtplugMessage): + """Success response from server.""" + + _message_type: ClassVar[str] = "Ok" + + +class Error(ButtplugMessage): + """Error response from server.""" + + _message_type: ClassVar[str] = "Error" + + error_message: str = Field(alias="ErrorMessage") + error_code: ErrorCode = Field(alias="ErrorCode") + + +class Ping(ButtplugMessage): + """Keepalive ping message.""" + + _message_type: ClassVar[str] = "Ping" + + +class Disconnect(ButtplugMessage): + """Graceful disconnection request.""" + + _message_type: ClassVar[str] = "Disconnect" + + +class StartScanning(ButtplugMessage): + """Start scanning for devices.""" + + _message_type: ClassVar[str] = "StartScanning" + + +class StopScanning(ButtplugMessage): + """Stop scanning for devices.""" + + _message_type: ClassVar[str] = "StopScanning" + + +class ScanningFinished(ButtplugMessage): + """Notification that scanning has completed.""" + + _message_type: ClassVar[str] = "ScanningFinished" + + +class RequestDeviceList(ButtplugMessage): + """Request list of connected devices.""" + + _message_type: ClassVar[str] = "RequestDeviceList" diff --git a/src/buttplug/_utils/__init__.py b/src/buttplug/_utils/__init__.py new file mode 100644 index 0000000..74693eb --- /dev/null +++ b/src/buttplug/_utils/__init__.py @@ -0,0 +1,6 @@ +"""Internal utilities.""" + +from buttplug._utils.events import EventHandler +from buttplug._utils.message_sorter import MessageSorter + +__all__ = ["EventHandler", "MessageSorter"] diff --git a/src/buttplug/_utils/events.py b/src/buttplug/_utils/events.py new file mode 100644 index 0000000..d3096c6 --- /dev/null +++ b/src/buttplug/_utils/events.py @@ -0,0 +1,54 @@ +"""Event handling utilities.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Generic, TypeVar + +T = TypeVar("T") + +# Type alias for event callbacks (sync or async) +Callback = Callable[[T], None] | Callable[[T], Awaitable[None]] + + +class EventHandler(Generic[T]): + """Simple event handler supporting both sync and async callbacks. + + Example: + handler: EventHandler[str] = EventHandler() + handler += lambda msg: print(msg) + await handler.emit("Hello") + """ + + def __init__(self) -> None: + self._callbacks: list[Callback[T]] = [] + + def __iadd__(self, callback: Callback[T]) -> EventHandler[T]: + """Add a callback using += operator.""" + self._callbacks.append(callback) + return self + + def __isub__(self, callback: Callback[T]) -> EventHandler[T]: + """Remove a callback using -= operator.""" + try: + self._callbacks.remove(callback) + except ValueError: + pass + return self + + async def emit(self, value: T) -> None: + """Emit event to all registered callbacks.""" + import inspect + + for callback in self._callbacks: + result = callback(value) + if inspect.isawaitable(result): + await result + + def clear(self) -> None: + """Remove all callbacks.""" + self._callbacks.clear() + + def __bool__(self) -> bool: + """True if any callbacks are registered.""" + return len(self._callbacks) > 0 diff --git a/src/buttplug/_utils/message_sorter.py b/src/buttplug/_utils/message_sorter.py new file mode 100644 index 0000000..bb37212 --- /dev/null +++ b/src/buttplug/_utils/message_sorter.py @@ -0,0 +1,84 @@ +"""Message sorting and request/response correlation.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from buttplug._messages.base import ButtplugMessage + + +class MessageSorter: + """Correlates outgoing requests with incoming responses by message Id. + + Thread-safe for concurrent request handling. + """ + + def __init__(self) -> None: + self._next_id = 1 + self._pending: dict[int, asyncio.Future[ButtplugMessage]] = {} + self._lock = asyncio.Lock() + + def get_next_id(self) -> int: + """Get next available message ID.""" + msg_id = self._next_id + self._next_id += 1 + if self._next_id > 4294967295: # Protocol max + self._next_id = 1 + return msg_id + + async def wait_for_response(self, msg_id: int, timeout: float = 30.0) -> ButtplugMessage: + """Wait for response with matching message ID. + + Args: + msg_id: Message ID to wait for. + timeout: Maximum seconds to wait. + + Returns: + Response message. + + Raises: + asyncio.TimeoutError: If timeout expires. + asyncio.CancelledError: If cancelled. + """ + future: asyncio.Future[ButtplugMessage] = asyncio.get_event_loop().create_future() + + async with self._lock: + self._pending[msg_id] = future + + try: + return await asyncio.wait_for(future, timeout=timeout) + finally: + async with self._lock: + self._pending.pop(msg_id, None) + + async def resolve(self, msg_id: int, response: ButtplugMessage) -> bool: + """Resolve pending request with response. + + Args: + msg_id: Message ID of request. + response: Response message. + + Returns: + True if a pending request was resolved, False otherwise. + """ + async with self._lock: + future = self._pending.get(msg_id) + if future and not future.done(): + future.set_result(response) + return True + return False + + async def reject_all(self, error: Exception) -> None: + """Reject all pending requests with an error.""" + async with self._lock: + for future in self._pending.values(): + if not future.done(): + future.set_exception(error) + self._pending.clear() + + @property + def pending_count(self) -> int: + """Number of pending requests.""" + return len(self._pending) diff --git a/src/buttplug/client.py b/src/buttplug/client.py new file mode 100644 index 0000000..05b8211 --- /dev/null +++ b/src/buttplug/client.py @@ -0,0 +1,445 @@ +"""Buttplug client for connecting to Buttplug servers.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +from buttplug._messages import ( + DeviceList, + Error, + Ping, + RequestDeviceList, + RequestServerInfo, + ScanningFinished, + ServerInfo, + StartScanning, + StopCmd, + StopScanning, +) +from buttplug._messages.base import ButtplugMessage +from buttplug.connector import WebSocketConnector +from buttplug.errors import ( + ButtplugConnectorError, + ButtplugHandshakeError, + ButtplugPingError, + error_from_code, +) + +if TYPE_CHECKING: + from buttplug.device import ButtplugDevice + + +class ButtplugClient: + """Client for connecting to and interacting with Buttplug servers. + + Example: + client = ButtplugClient("My App") + + # Set up event handlers before connecting + client.on_device_added = lambda d: print(f"Found: {d.name}") + client.on_device_removed = lambda d: print(f"Lost: {d.name}") + + await client.connect("ws://127.0.0.1:12345") + await client.start_scanning() + # ... use devices ... + await client.disconnect() + """ + + def __init__(self, name: str) -> None: + """Initialize client. + + Args: + name: Client application name (sent to server during handshake). + """ + self._name = name + self._connector: WebSocketConnector | None = None + self._connected = False + self._scanning = False + + # Server info from handshake + self._server_name: str | None = None + self._max_ping_time: int = 0 + + # Ping management + self._ping_task: asyncio.Task[None] | None = None + + # Device tracking + self._devices: dict[int, ButtplugDevice] = {} + + # Event callbacks + self._on_device_added: ( + Callable[[ButtplugDevice], None] | Callable[[ButtplugDevice], Awaitable[None]] | None + ) = None + self._on_device_removed: ( + Callable[[ButtplugDevice], None] | Callable[[ButtplugDevice], Awaitable[None]] | None + ) = None + self._on_scanning_finished: Callable[[], None] | Callable[[], Awaitable[None]] | None = None + self._on_server_disconnect: Callable[[], None] | Callable[[], Awaitable[None]] | None = None + self._on_error: ( + Callable[[Exception], None] | Callable[[Exception], Awaitable[None]] | None + ) = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + if self.connected: + await self.disconnect() + + @property + def name(self) -> str: + """Client application name.""" + return self._name + + @property + def connected(self) -> bool: + """True if connected to a server.""" + return self._connected + + @property + def server_name(self) -> str | None: + """Name of connected server, or None if not connected.""" + return self._server_name + + @property + def devices(self) -> dict[int, ButtplugDevice]: + """Dictionary of connected devices by device index.""" + return self._devices + + @property + def scanning(self) -> bool: + """True if currently scanning for devices.""" + return self._scanning + + # Event callback properties + @property + def on_device_added( + self, + ) -> Callable[[ButtplugDevice], None] | Callable[[ButtplugDevice], Awaitable[None]] | None: + """Callback when a device connects.""" + return self._on_device_added + + @on_device_added.setter + def on_device_added( + self, + callback: Callable[[ButtplugDevice], None] + | Callable[[ButtplugDevice], Awaitable[None]] + | None, + ) -> None: + self._on_device_added = callback + + @property + def on_device_removed( + self, + ) -> Callable[[ButtplugDevice], None] | Callable[[ButtplugDevice], Awaitable[None]] | None: + """Callback when a device disconnects.""" + return self._on_device_removed + + @on_device_removed.setter + def on_device_removed( + self, + callback: Callable[[ButtplugDevice], None] + | Callable[[ButtplugDevice], Awaitable[None]] + | None, + ) -> None: + self._on_device_removed = callback + + @property + def on_scanning_finished( + self, + ) -> Callable[[], None] | Callable[[], Awaitable[None]] | None: + """Callback when device scanning completes.""" + return self._on_scanning_finished + + @on_scanning_finished.setter + def on_scanning_finished( + self, callback: Callable[[], None] | Callable[[], Awaitable[None]] | None + ) -> None: + self._on_scanning_finished = callback + + @property + def on_server_disconnect( + self, + ) -> Callable[[], None] | Callable[[], Awaitable[None]] | None: + """Callback when server disconnects unexpectedly.""" + return self._on_server_disconnect + + @on_server_disconnect.setter + def on_server_disconnect( + self, callback: Callable[[], None] | Callable[[], Awaitable[None]] | None + ) -> None: + self._on_server_disconnect = callback + + @property + def on_error( + self, + ) -> Callable[[Exception], None] | Callable[[Exception], Awaitable[None]] | None: + """Callback when an error is received from the server.""" + return self._on_error + + @on_error.setter + def on_error( + self, + callback: Callable[[Exception], None] | Callable[[Exception], Awaitable[None]] | None, + ) -> None: + self._on_error = callback + + async def connect(self, url: str) -> None: + """Connect to a Buttplug server. + + Args: + url: WebSocket URL (e.g., "ws://127.0.0.1:12345") + + Raises: + ButtplugConnectorError: If connection fails. + ButtplugHandshakeError: If handshake fails or version mismatch. + """ + if self._connected: + return + + # Create connector and set up callbacks + self._connector = WebSocketConnector(url) + self._connector.set_message_callback(self._handle_server_message) + self._connector.set_disconnect_callback(self._handle_disconnect) + + # Connect WebSocket + await self._connector.connect() + + # Perform handshake + try: + request = RequestServerInfo(id=0, client_name=self._name) + response = await self._connector.send(request) + + if isinstance(response, Error): + raise ButtplugHandshakeError(response.error_message) + + if not isinstance(response, ServerInfo): + raise ButtplugHandshakeError(f"Unexpected response: {type(response).__name__}") + + self._server_name = response.server_name + self._max_ping_time = response.max_ping_time + self._connected = True + + # Start ping timer if required + if self._max_ping_time > 0: + self._start_ping_timer() + + # Request initial device list + await self._request_device_list() + + except Exception: + await self._connector.disconnect() + self._connector = None + raise + + async def disconnect(self) -> None: + """Disconnect from the server. + + Automatically stops all devices before disconnecting. + """ + if not self._connected or not self._connector: + return + + self._connected = False + + # Stop ping timer + self._stop_ping_timer() + + # Stop all devices + try: + await self.stop_all_devices() + except Exception: + pass + + # Disconnect + await self._connector.disconnect() + self._connector = None + + # Clear state + self._devices.clear() + self._server_name = None + self._scanning = False + + async def start_scanning(self) -> None: + """Start scanning for devices. + + New devices will trigger on_device_added callback. + + Raises: + ButtplugConnectorError: If not connected. + """ + if not self._connector or not self._connected: + raise ButtplugConnectorError("Not connected") + + msg = StartScanning(id=0) + response = await self._connector.send(msg) + + if isinstance(response, Error): + raise error_from_code(response.error_code, response.error_message) + + self._scanning = True + + async def stop_scanning(self) -> None: + """Stop scanning for devices. + + Raises: + ButtplugConnectorError: If not connected. + """ + if not self._connector or not self._connected: + raise ButtplugConnectorError("Not connected") + + msg = StopScanning(id=0) + response = await self._connector.send(msg) + + if isinstance(response, Error): + raise error_from_code(response.error_code, response.error_message) + + self._scanning = False + + async def stop_all_devices(self) -> None: + """Stop all devices. + + Raises: + ButtplugConnectorError: If not connected. + """ + if not self._connector or not self._connected: + raise ButtplugConnectorError("Not connected") + + msg = StopCmd(id=0) + response = await self._connector.send(msg) + + if isinstance(response, Error): + raise error_from_code(response.error_code, response.error_message) + + async def _request_device_list(self) -> None: + """Request current device list from server.""" + if not self._connector: + return + + msg = RequestDeviceList(id=0) + response = await self._connector.send(msg) + + if isinstance(response, DeviceList): + await self._handle_device_list(response) + + async def _handle_server_message(self, msg: ButtplugMessage) -> None: + """Handle unsolicited messages from server.""" + import inspect + + if isinstance(msg, DeviceList): + await self._handle_device_list(msg) + elif isinstance(msg, ScanningFinished): + self._scanning = False + if self._on_scanning_finished: + result = self._on_scanning_finished() + if inspect.isawaitable(result): + await result + elif isinstance(msg, Error): + error = error_from_code(msg.error_code, msg.error_message) + if self._on_error: + result = self._on_error(error) + if inspect.isawaitable(result): + await result + + async def _handle_device_list(self, device_list: DeviceList) -> None: + """Process device list and emit add/remove events.""" + import inspect + + from buttplug.device import ButtplugDevice + + # Find new and removed devices + current_indices = set(self._devices.keys()) + new_indices = set(device_list.devices.keys()) + + added_indices = new_indices - current_indices + removed_indices = current_indices - new_indices + + # Process removals first + for index in removed_indices: + device = self._devices.pop(index) + if self._on_device_removed: + result = self._on_device_removed(device) + if inspect.isawaitable(result): + await result + + # Process additions + for index in added_indices: + device_info = device_list.devices[index] + device = ButtplugDevice(self, device_info) + self._devices[index] = device + if self._on_device_added: + result = self._on_device_added(device) + if inspect.isawaitable(result): + await result + + async def _handle_disconnect(self) -> None: + """Handle unexpected disconnection from server.""" + import inspect + + self._connected = False + self._stop_ping_timer() + self._devices.clear() + self._scanning = False + + if self._on_server_disconnect: + result = self._on_server_disconnect() + if inspect.isawaitable(result): + await result + + def _start_ping_timer(self) -> None: + """Start the ping timer task.""" + if self._ping_task: + return + + self._ping_task = asyncio.create_task(self._ping_loop()) + + def _stop_ping_timer(self) -> None: + """Stop the ping timer task.""" + if self._ping_task: + self._ping_task.cancel() + self._ping_task = None + + async def _ping_loop(self) -> None: + """Background task to send periodic pings.""" + # Ping at half the max ping time for safety margin + interval = self._max_ping_time / 2000.0 # Convert ms to seconds + + try: + while self._connected and self._connector: + await asyncio.sleep(interval) + + if not self._connected or not self._connector: + break + + try: + msg = Ping(id=0) + response = await self._connector.send(msg) + + if isinstance(response, Error): + error = ButtplugPingError(response.error_message) + if self._on_error: + import inspect + + result = self._on_error(error) + if inspect.isawaitable(result): + await result + except Exception: + pass # Don't crash on ping failures + + except asyncio.CancelledError: + pass + + async def _send_device_message(self, msg: ButtplugMessage) -> ButtplugMessage: + """Send a device message and return response. + + Internal method used by ButtplugDevice. + + Raises: + ButtplugConnectorError: If not connected. + """ + if not self._connector or not self._connected: + raise ButtplugConnectorError("Not connected") + + return await self._connector.send(msg) diff --git a/src/buttplug/command.py b/src/buttplug/command.py new file mode 100644 index 0000000..0382762 --- /dev/null +++ b/src/buttplug/command.py @@ -0,0 +1,23 @@ +"""Device output command for unified output control.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from buttplug.enums import OutputType +from buttplug.feature import CommandValue + + +@dataclass(frozen=True) +class DeviceOutputCommand: + """A command to send to a device output. + + Args: + output_type: The type of output to control (e.g., OutputType.VIBRATE). + value: Float 0.0-1.0 (percent) or int (step value). + duration: Duration in ms, only for POSITION_WITH_DURATION. + """ + + output_type: OutputType + value: CommandValue + duration: int | None = None diff --git a/src/buttplug/connector.py b/src/buttplug/connector.py new file mode 100644 index 0000000..68af2c6 --- /dev/null +++ b/src/buttplug/connector.py @@ -0,0 +1,191 @@ +"""WebSocket connector for Buttplug server communication.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from typing import TYPE_CHECKING + +import websockets +from websockets.asyncio.client import ClientConnection + +from buttplug._messages.base import ButtplugMessage, parse_messages +from buttplug._utils.message_sorter import MessageSorter +from buttplug.errors import ButtplugConnectorError + +if TYPE_CHECKING: + from collections.abc import Awaitable + + +class WebSocketConnector: + """WebSocket connector for Buttplug server communication. + + Handles WebSocket connection, message serialization/deserialization, + and request/response correlation. + + Not intended for direct use - ButtplugClient handles this internally. + """ + + def __init__(self, url: str) -> None: + """Initialize connector. + + Args: + url: WebSocket URL (e.g., "ws://127.0.0.1:12345") + """ + self._url = url + self._ws: ClientConnection | None = None + self._message_sorter = MessageSorter() + self._receive_task: asyncio.Task[None] | None = None + self._connected = False + + # Callbacks for unsolicited messages + self._on_message: Callable[[ButtplugMessage], Awaitable[None]] | None = None + self._on_disconnect: Callable[[], Awaitable[None]] | None = None + + @property + def connected(self) -> bool: + """True if currently connected.""" + return self._connected and self._ws is not None + + def set_message_callback(self, callback: Callable[[ButtplugMessage], Awaitable[None]]) -> None: + """Set callback for unsolicited server messages (Id=0).""" + self._on_message = callback + + def set_disconnect_callback(self, callback: Callable[[], Awaitable[None]]) -> None: + """Set callback for disconnection events.""" + self._on_disconnect = callback + + async def connect(self) -> None: + """Connect to the Buttplug server. + + Raises: + ButtplugConnectorError: If connection fails. + """ + if self._connected: + return + + try: + self._ws = await websockets.connect(self._url) + self._connected = True + self._receive_task = asyncio.create_task(self._receive_loop()) + except Exception as e: + raise ButtplugConnectorError(f"Failed to connect to {self._url}: {e}") from e + + async def disconnect(self) -> None: + """Disconnect from the server.""" + self._connected = False + + if self._receive_task: + self._receive_task.cancel() + try: + await self._receive_task + except asyncio.CancelledError: + pass + self._receive_task = None + + if self._ws: + try: + await self._ws.close() + except Exception: + pass + self._ws = None + + # Reject any pending requests + await self._message_sorter.reject_all(ButtplugConnectorError("Connection closed")) + + async def send(self, message: ButtplugMessage, timeout: float = 30.0) -> ButtplugMessage: + """Send message and wait for response. + + Args: + message: Message to send. + timeout: Maximum seconds to wait for response. + + Returns: + Response message from server. + + Raises: + ButtplugConnectorError: If not connected or send fails. + asyncio.TimeoutError: If response timeout expires. + """ + if not self._ws or not self._connected: + raise ButtplugConnectorError("Not connected") + + # Assign message ID if not set + msg_id = self._message_sorter.get_next_id() + message.id = msg_id + + # Serialize and send + protocol_data = message.to_protocol() + json_str = json.dumps(protocol_data) + + try: + await self._ws.send(json_str) + except Exception as e: + raise ButtplugConnectorError(f"Failed to send message: {e}") from e + + # Wait for response with matching ID + return await self._message_sorter.wait_for_response(msg_id, timeout) + + async def send_no_response(self, message: ButtplugMessage) -> None: + """Send message without waiting for response. + + Used for messages that don't expect a reply (like Ping with manual handling). + + Args: + message: Message to send. + + Raises: + ButtplugConnectorError: If not connected or send fails. + """ + if not self._ws or not self._connected: + raise ButtplugConnectorError("Not connected") + + # Assign message ID if not set + if message.id == 0: + message.id = self._message_sorter.get_next_id() + + protocol_data = message.to_protocol() + json_str = json.dumps(protocol_data) + + try: + await self._ws.send(json_str) + except Exception as e: + raise ButtplugConnectorError(f"Failed to send message: {e}") from e + + async def _receive_loop(self) -> None: + """Background task to receive and dispatch messages.""" + try: + while self._connected and self._ws: + try: + raw = await self._ws.recv() + except websockets.exceptions.ConnectionClosed: + break + + try: + data = json.loads(raw) + messages = parse_messages(data) + except (json.JSONDecodeError, ValueError): + # Log but don't crash on parse errors + continue + + for msg in messages: + if msg.id == 0: + # Unsolicited message - dispatch to callback + if self._on_message: + try: + await self._on_message(msg) + except Exception: + pass # Don't let callback errors crash loop + else: + # Response to a request - resolve pending future + await self._message_sorter.resolve(msg.id, msg) + + finally: + if self._connected: + self._connected = False + if self._on_disconnect: + try: + await self._on_disconnect() + except Exception: + pass diff --git a/src/buttplug/device.py b/src/buttplug/device.py new file mode 100644 index 0000000..7ae0a31 --- /dev/null +++ b/src/buttplug/device.py @@ -0,0 +1,173 @@ +"""Buttplug device for controlling hardware.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from buttplug._messages import ( + ButtplugMessage, + Error, + Ok, + StopCmd, +) +from buttplug._messages.device_info import DeviceInfo +from buttplug.command import DeviceOutputCommand +from buttplug.enums import InputType, OutputType +from buttplug.errors import ButtplugDeviceError, error_from_code +from buttplug.feature import DeviceFeature + +if TYPE_CHECKING: + from buttplug.client import ButtplugClient + + +class ButtplugDevice: + """Represents a connected Buttplug device. + + Provides both device-level run_output() that controls all matching features, + and per-feature access for fine-grained control. + + Command values can be specified as: + - float: Normalized percentage from 0.0 to 1.0 (e.g., 0.5 = 50%) + - int: Direct step value (e.g., 10 out of 20 steps) + + Example: + # Device-level: control all vibrators at once + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) + + # Feature-level: control specific motor + feature = device.features[0] + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.75)) + + # Using steps instead of percent + step_count = feature.step_count(OutputType.VIBRATE) + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, step_count // 2)) + """ + + def __init__(self, client: ButtplugClient, device_info: DeviceInfo) -> None: + """Initialize device from protocol device info.""" + self._client = client + self._info = device_info + self._features: dict[int, DeviceFeature] = { + idx: DeviceFeature(client, device_info.device_index, defn) + for idx, defn in device_info.device_features.items() + } + + @property + def index(self) -> int: + """Device index (unique identifier from server).""" + return self._info.device_index + + @property + def name(self) -> str: + """Device name from server configuration.""" + return self._info.device_name + + @property + def display_name(self) -> str | None: + """User-provided display name, or None if not set.""" + return self._info.device_display_name + + @property + def message_timing_gap(self) -> int: + """Minimum milliseconds between commands (enforced by server).""" + return self._info.device_message_timing_gap + + @property + def features(self) -> dict[int, DeviceFeature]: + """Dictionary of device features by feature index. + + Use this for per-feature control when a device has multiple + motors, sensors, or other capabilities. + """ + return self._features + + def has_output(self, output_type: OutputType | str) -> bool: + """Check if device has any feature with the specified output type.""" + return any(f.has_output(output_type) for f in self._features.values()) + + def has_input(self, input_type: InputType | str) -> bool: + """Check if device has any feature with the specified input type.""" + return any(f.has_input(input_type) for f in self._features.values()) + + def get_features_with_output(self, output_type: OutputType | str) -> list[DeviceFeature]: + """Get all features that support a specific output type.""" + return [f for f in self._features.values() if f.has_output(output_type)] + + def get_features_with_input(self, input_type: InputType | str) -> list[DeviceFeature]: + """Get all features that support a specific input type.""" + return [f for f in self._features.values() if f.has_input(input_type)] + + # ============ Device-Level Command Methods ============ + + async def run_output(self, command: DeviceOutputCommand) -> None: + """Send an output command to all features matching the output type. + + Args: + command: The output command specifying type, value, and optional duration. + + Raises: + ButtplugDeviceError: If device has no features matching the output type. + """ + features = self.get_features_with_output(command.output_type) + if not features: + raise ButtplugDeviceError(f"Device has no {command.output_type.value} features") + await asyncio.gather(*[f.run_output(command) for f in features]) + + async def stop(self, inputs: bool = True, outputs: bool = True) -> None: + """Stop device outputs and/or unsubscribe from inputs. + + Args: + inputs: If True, unsubscribe from all input subscriptions. + outputs: If True, stop all outputs. + """ + msg = StopCmd(id=0, device_index=self.index, inputs=inputs, outputs=outputs) + response = await self._client._send_device_message(msg) + self._check_response(response) + + # ============ Sensor Convenience Methods ============ + + def has_battery(self) -> bool: + """Check if device has a battery level sensor.""" + return self.has_input(InputType.BATTERY) + + async def battery(self) -> float: + """Read battery level from first battery sensor. + + Returns: + Battery level from 0.0 (empty) to 1.0 (full). + + Raises: + ButtplugDeviceError: If device has no battery sensor. + """ + features = self.get_features_with_input(InputType.BATTERY) + if not features: + raise ButtplugDeviceError("Device has no battery sensor") + return await features[0].battery() + + def has_rssi(self) -> bool: + """Check if device has an RSSI sensor.""" + return self.has_input(InputType.RSSI) + + async def rssi(self) -> int: + """Read RSSI signal strength from first RSSI sensor. + + Returns: + RSSI value (typically -10 to -100 dBm). + + Raises: + ButtplugDeviceError: If device has no RSSI sensor. + """ + features = self.get_features_with_input(InputType.RSSI) + if not features: + raise ButtplugDeviceError("Device has no RSSI sensor") + return await features[0].rssi() + + # ============ Internal Methods ============ + + def _check_response(self, response: ButtplugMessage) -> None: + """Check response and raise if error.""" + if isinstance(response, Error): + raise error_from_code(response.error_code, response.error_message) + if not isinstance(response, Ok): + raise ButtplugDeviceError(f"Unexpected response: {type(response).__name__}") diff --git a/src/buttplug/enums.py b/src/buttplug/enums.py new file mode 100644 index 0000000..97c1731 --- /dev/null +++ b/src/buttplug/enums.py @@ -0,0 +1,55 @@ +"""Enums for Buttplug protocol types.""" + +import sys +from enum import IntEnum + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + """String enum for Python 3.10 compatibility.""" + + pass + + +class OutputType(StrEnum): + """Device output types for controlling actuators.""" + + VIBRATE = "Vibrate" + ROTATE = "Rotate" + OSCILLATE = "Oscillate" + CONSTRICT = "Constrict" + SPRAY = "Spray" + TEMPERATURE = "Temperature" + LED = "Led" + POSITION = "Position" + POSITION_WITH_DURATION = "HwPositionWithDuration" + + +class InputType(StrEnum): + """Device input types for reading sensors.""" + + BATTERY = "Battery" + RSSI = "RSSI" + PRESSURE = "Pressure" + BUTTON = "Button" + + +class InputCommandType(StrEnum): + """Commands for input sensors.""" + + READ = "Read" + SUBSCRIBE = "Subscribe" + UNSUBSCRIBE = "Unsubscribe" + + +class ErrorCode(IntEnum): + """Protocol error codes.""" + + UNKNOWN = 0 + INIT = 1 + PING = 2 + MSG = 3 + DEVICE = 4 diff --git a/src/buttplug/errors.py b/src/buttplug/errors.py new file mode 100644 index 0000000..d17efc7 --- /dev/null +++ b/src/buttplug/errors.py @@ -0,0 +1,62 @@ +"""Exception hierarchy for Buttplug errors.""" + +from buttplug.enums import ErrorCode + + +class ButtplugError(Exception): + """Base exception for all Buttplug errors.""" + + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +class ButtplugConnectorError(ButtplugError): + """Connection-related errors (WebSocket failures, disconnections).""" + + pass + + +class ButtplugHandshakeError(ButtplugError): + """Handshake failed (version mismatch, server rejection).""" + + pass + + +class ButtplugPingError(ButtplugError): + """Ping timeout - server didn't receive ping in time.""" + + pass + + +class ButtplugDeviceError(ButtplugError): + """Device command failed (device disconnected, invalid command).""" + + pass + + +class ButtplugMessageError(ButtplugError): + """Message parsing or permission error.""" + + pass + + +class ButtplugUnknownError(ButtplugError): + """Unknown error from server.""" + + pass + + +def error_from_code(code: ErrorCode, message: str) -> ButtplugError: + """Create appropriate exception from error code.""" + match code: + case ErrorCode.INIT: + return ButtplugHandshakeError(message) + case ErrorCode.PING: + return ButtplugPingError(message) + case ErrorCode.MSG: + return ButtplugMessageError(message) + case ErrorCode.DEVICE: + return ButtplugDeviceError(message) + case _: + return ButtplugUnknownError(message) diff --git a/src/buttplug/feature.py b/src/buttplug/feature.py new file mode 100644 index 0000000..eb48eec --- /dev/null +++ b/src/buttplug/feature.py @@ -0,0 +1,304 @@ +"""Device feature for low-level access to device capabilities.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from buttplug._messages.device_info import ( + DeviceFeatureDefinition, + FeatureInputDefinition, + FeatureOutputDefinition, +) +from buttplug.enums import InputCommandType, InputType, OutputType +from buttplug.errors import ButtplugDeviceError + +if TYPE_CHECKING: + from buttplug.client import ButtplugClient + from buttplug.command import DeviceOutputCommand + + +# Type alias for command values - can be float (0.0-1.0 percent) or int (steps) +CommandValue = float | int + + +class DeviceFeature: + """Represents a single feature of a device. + + Features are the individual capabilities of a device, such as a vibrator motor, + a battery sensor, or a rotation mechanism. Each feature has an index, optional + description, and defines its supported outputs and/or inputs. + + Command values can be specified as: + - float: Normalized percentage from 0.0 to 1.0 (e.g., 0.5 = 50%) + - int: Direct step value (e.g., 10 out of 20 steps) + + Example: + # Using percent (recommended for most cases) + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) + + # Using steps (for precise hardware control) + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, 10)) + + # Check step range first + print(f"Steps: {feature.step_count(OutputType.VIBRATE)}") + """ + + def __init__( + self, client: ButtplugClient, device_index: int, definition: DeviceFeatureDefinition + ) -> None: + """Initialize from protocol feature definition.""" + self._client = client + self._device_index = device_index + self._definition = definition + + @property + def index(self) -> int: + """Feature index (unique within device).""" + return self._definition.feature_index + + @property + def description(self) -> str | None: + """Human-readable feature description (e.g., "Clitoral Stimulator").""" + return self._definition.feature_description + + @property + def outputs(self) -> dict[str, FeatureOutputDefinition] | None: + """Output types supported by this feature, or None if no outputs.""" + return self._definition.output + + @property + def inputs(self) -> dict[str, FeatureInputDefinition] | None: + """Input types supported by this feature, or None if no inputs.""" + return self._definition.input + + def has_output(self, output_type: OutputType | str) -> bool: + """Check if this feature supports a specific output type.""" + if self._definition.output is None: + return False + output_name = output_type.value if isinstance(output_type, OutputType) else output_type + return output_name in self._definition.output + + def has_input(self, input_type: InputType | str) -> bool: + """Check if this feature supports a specific input type.""" + if self._definition.input is None: + return False + input_name = input_type.value if isinstance(input_type, InputType) else input_type + return input_name in self._definition.input + + def supports_input_command( + self, input_type: InputType | str, command: InputCommandType + ) -> bool: + """Check if this feature supports a specific input command.""" + if self._definition.input is None: + return False + input_name = input_type.value if isinstance(input_type, InputType) else input_type + if input_name not in self._definition.input: + return False + return command.value in self._definition.input[input_name].command + + def step_range(self, output_type: OutputType | str) -> tuple[int, int] | None: + """Get the step value range for an output type. + + Args: + output_type: Output type (e.g., OutputType.VIBRATE) + + Returns: + Tuple of (min_step, max_step), or None if output not supported. + """ + if self._definition.output is None: + return None + output_name = output_type.value if isinstance(output_type, OutputType) else output_type + if output_name not in self._definition.output: + return None + return self._definition.output[output_name].value + + def step_count(self, output_type: OutputType | str) -> int | None: + """Get the number of steps for an output type. + + This is the maximum step value (e.g., 20 means steps 0-20). + + Args: + output_type: Output type (e.g., OutputType.VIBRATE) + + Returns: + Maximum step value, or None if output not supported. + """ + step_range = self.step_range(output_type) + if step_range is None: + return None + return step_range[1] + + def duration_range(self, output_type: OutputType | str) -> tuple[int, int] | None: + """Get the duration range for an output type (e.g., position with duration). + + Args: + output_type: Output type (e.g., OutputType.POSITION_WITH_DURATION) + + Returns: + Tuple of (min_ms, max_ms) duration, or None if not supported. + """ + if self._definition.output is None: + return None + output_name = output_type.value if isinstance(output_type, OutputType) else output_type + if output_name not in self._definition.output: + return None + return self._definition.output[output_name].duration + + def convert_to_step(self, output_type: OutputType | str, value: CommandValue) -> int: + """Convert a command value (float or int) to a step value. + + Args: + output_type: The output type to convert for. + value: Either a float (0.0-1.0 percent) or int (step value). + + Returns: + The step value to send to the device. + + Raises: + ButtplugDeviceError: If value is out of range or output not supported. + """ + step_range = self.step_range(output_type) + if step_range is None: + output_name = output_type.value if isinstance(output_type, OutputType) else output_type + raise ButtplugDeviceError(f"Feature does not support {output_name}") + + min_step, max_step = step_range + + if isinstance(value, float): + # Validate percent range + if not -1.0 <= value <= 1.0: + raise ButtplugDeviceError(f"Float value {value} must be between -1.0 and 1.0") + + # Convert percent to step + if value >= 0: + step = int(math.ceil(value * max_step)) + else: + step = int(math.floor(value * max_step)) + else: + # Direct step value + step = value + + # Validate step range + if not min_step <= step <= max_step: + raise ButtplugDeviceError(f"Step value {step} out of range [{min_step}, {max_step}]") + + return step + + # ============ Feature-Level Command Methods ============ + + async def run_output(self, command: DeviceOutputCommand) -> None: + """Send an output command to this feature. + + Args: + command: The output command specifying type, value, and optional duration. + + Raises: + ButtplugDeviceError: If this feature doesn't support the output type. + """ + if command.output_type == OutputType.POSITION_WITH_DURATION: + await self._send_position_with_duration(command.value, command.duration or 0) + else: + await self._send_output(command.output_type, command.value) + + async def stop(self) -> None: + """Stop this feature's outputs.""" + from buttplug._messages import StopCmd + + msg = StopCmd( + id=0, + device_index=self._device_index, + feature_index=self.index, + outputs=True, + inputs=False, + ) + response = await self._client._send_device_message(msg) + self._check_response(response) + + async def battery(self) -> float: + """Read battery level (0.0-1.0).""" + reading = await self._read_input(InputType.BATTERY) + return reading / 100.0 + + async def rssi(self) -> int: + """Read RSSI signal strength (dBm).""" + return await self._read_input(InputType.RSSI) + + # ============ Internal Methods ============ + + async def _send_output(self, output_type: OutputType, value: CommandValue) -> None: + """Send an output command to this feature.""" + from buttplug._messages import OutputCmd + + output_name = output_type.value + step = self.convert_to_step(output_type, value) + + msg = OutputCmd( + id=0, + device_index=self._device_index, + feature_index=self.index, + command={output_name: {"Value": step}}, + ) + response = await self._client._send_device_message(msg) + self._check_response(response) + + async def _send_position_with_duration(self, value: CommandValue, duration_ms: int) -> None: + """Send position with duration command.""" + from buttplug._messages import OutputCmd + + step = self.convert_to_step(OutputType.POSITION_WITH_DURATION, value) + + # Clamp duration to allowed range + duration_range = self.duration_range(OutputType.POSITION_WITH_DURATION) + if duration_range: + min_dur, max_dur = duration_range + duration_ms = max(min_dur, min(max_dur, duration_ms)) + + msg = OutputCmd( + id=0, + device_index=self._device_index, + feature_index=self.index, + command={"HwPositionWithDuration": {"Value": step, "Duration": duration_ms}}, + ) + response = await self._client._send_device_message(msg) + self._check_response(response) + + async def _read_input(self, input_type: InputType) -> int: + """Read raw input value.""" + from buttplug._messages import Error, InputCmd, InputReading + + input_name = input_type.value + if not self.has_input(input_type): + raise ButtplugDeviceError(f"Feature does not support {input_name} input") + + msg = InputCmd( + id=0, + device_index=self._device_index, + feature_index=self.index, + input_type=input_name, + command=InputCommandType.READ.value, + ) + response = await self._client._send_device_message(msg) + + if isinstance(response, Error): + from buttplug.errors import error_from_code + + raise error_from_code(response.error_code, response.error_message) + + if not isinstance(response, InputReading): + raise ButtplugDeviceError(f"Unexpected response: {type(response).__name__}") + + if input_name not in response.reading: + raise ButtplugDeviceError(f"Invalid {input_name} reading response") + + return response.reading[input_name].value + + def _check_response(self, response: object) -> None: + """Check response and raise if error.""" + from buttplug._messages import Error, Ok + from buttplug.errors import error_from_code + + if isinstance(response, Error): + raise error_from_code(response.error_code, response.error_message) + if not isinstance(response, Ok): + raise ButtplugDeviceError(f"Unexpected response: {type(response).__name__}") diff --git a/src/buttplug/py.typed b/src/buttplug/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..f422579 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Buttplug test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..20e8ce9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,56 @@ +"""Test configuration and fixtures.""" + +import pytest + + +@pytest.fixture +def sample_device_list_data() -> dict: + """Sample DeviceList message data from protocol.""" + return { + "Id": 1, + "Devices": { + "0": { + "DeviceName": "Test Vibrator", + "DeviceIndex": 0, + "DeviceMessageTimingGap": 50, + "DeviceDisplayName": "My Vibrator", + "DeviceFeatures": { + "0": { + "FeatureIndex": 0, + "FeatureDescription": "Clitoral Stimulator", + "Output": {"Vibrate": {"Value": [0, 20]}}, + }, + "1": { + "FeatureIndex": 1, + "FeatureDescription": "G-Spot Motor", + "Output": {"Vibrate": {"Value": [0, 20]}}, + }, + "2": { + "FeatureIndex": 2, + "FeatureDescription": "Battery", + "Input": {"Battery": {"Value": [[0, 100]], "Command": ["Read"]}}, + }, + }, + }, + "1": { + "DeviceName": "Test Stroker", + "DeviceIndex": 1, + "DeviceMessageTimingGap": 100, + "DeviceFeatures": { + "0": { + "FeatureIndex": 0, + "FeatureDescription": "Stroker", + "Output": { + "HwPositionWithDuration": {"Value": [0, 100], "Duration": [0, 1000]} + }, + } + }, + }, + }, + } + + +@pytest.fixture +def sample_input_reading_data() -> dict: + """Sample InputReading message data from protocol.""" + return {"Id": 5, "DeviceIndex": 0, "FeatureIndex": 2, "Reading": {"Battery": {"Value": 75}}} diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..6ee570e --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,89 @@ +"""Tests for ButtplugClient (unit tests without network).""" + +import pytest + +from buttplug import ButtplugClient +from buttplug.errors import ButtplugConnectorError + + +class TestButtplugClientInit: + """Tests for client initialization and properties.""" + + def test_client_name(self): + """Client stores name.""" + client = ButtplugClient("My Test App") + assert client.name == "My Test App" + + def test_initial_state(self): + """Client starts in disconnected state.""" + client = ButtplugClient("Test") + + assert client.connected is False + assert client.server_name is None + assert client.scanning is False + assert len(client.devices) == 0 + + def test_event_callbacks_none_by_default(self): + """Event callbacks are None by default.""" + client = ButtplugClient("Test") + + assert client.on_device_added is None + assert client.on_device_removed is None + assert client.on_scanning_finished is None + assert client.on_server_disconnect is None + assert client.on_error is None + + def test_set_event_callbacks(self): + """Event callbacks can be set.""" + client = ButtplugClient("Test") + + added_callback = lambda d: None + removed_callback = lambda d: None + scanning_callback = lambda: None + disconnect_callback = lambda: None + error_callback = lambda e: None + + client.on_device_added = added_callback + client.on_device_removed = removed_callback + client.on_scanning_finished = scanning_callback + client.on_server_disconnect = disconnect_callback + client.on_error = error_callback + + assert client.on_device_added is added_callback + assert client.on_device_removed is removed_callback + assert client.on_scanning_finished is scanning_callback + assert client.on_server_disconnect is disconnect_callback + assert client.on_error is error_callback + + +class TestButtplugClientNotConnected: + """Tests for client methods when not connected.""" + + async def test_start_scanning_not_connected(self): + """start_scanning raises when not connected.""" + client = ButtplugClient("Test") + + with pytest.raises(ButtplugConnectorError, match="Not connected"): + await client.start_scanning() + + async def test_stop_scanning_not_connected(self): + """stop_scanning raises when not connected.""" + client = ButtplugClient("Test") + + with pytest.raises(ButtplugConnectorError, match="Not connected"): + await client.stop_scanning() + + async def test_stop_all_devices_not_connected(self): + """stop_all_devices raises when not connected.""" + client = ButtplugClient("Test") + + with pytest.raises(ButtplugConnectorError, match="Not connected"): + await client.stop_all_devices() + + async def test_disconnect_when_not_connected(self): + """disconnect is safe to call when not connected.""" + client = ButtplugClient("Test") + + # Should not raise + await client.disconnect() + assert client.connected is False diff --git a/tests/test_client_device.py b/tests/test_client_device.py deleted file mode 100644 index a5d2e71..0000000 --- a/tests/test_client_device.py +++ /dev/null @@ -1,116 +0,0 @@ -import unittest -import pytest -from buttplug.core import (ButtplugMessage, Ok, Error, ButtplugErrorCode, - Test, DeviceAdded, MessageAttributes, DeviceRemoved, - DeviceInfo, DeviceList, VibrateCmd, SpeedSubcommand, - RotateCmd, RotateSubcommand, LinearCmd, - LinearSubcommand) -from buttplug.client import ButtplugClientDevice - - -class DummyClient(object): - def __init__(self): - self.last_message: ButtplugMessage = None - - async def _send_message_expect_ok(self, msg: ButtplugMessage): - print("Got message") - self.last_message = msg - - -@pytest.mark.asyncio -async def test_device_vibrate_single_argument(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Vibration Device", - 0, - {"VibrateCmd": - {"FeatureCount": 1}})) - await dev.send_vibrate_cmd(1.0) - assert client.last_message == VibrateCmd(0, [SpeedSubcommand(0, 1.0)]) - - -@pytest.mark.asyncio -async def test_device_vibrate_list(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Vibration Device", - 0, - {"VibrateCmd": - {"FeatureCount": 1}})) - await dev.send_vibrate_cmd([1.0]) - assert client.last_message == VibrateCmd(0, [SpeedSubcommand(0, 1.0)]) - - -@pytest.mark.asyncio -async def test_device_vibrate_dict(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Vibration Device", - 0, - {"VibrateCmd": - {"FeatureCount": 1}})) - await dev.send_vibrate_cmd({0: 1.0}) - assert client.last_message == VibrateCmd(0, [SpeedSubcommand(0, 1.0)]) - - -@pytest.mark.asyncio -async def test_device_rotate_single_argument(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", - 0, - {"RotateCmd": - {"FeatureCount": 1}})) - await dev.send_rotate_cmd((1.0, True)) - assert client.last_message == RotateCmd(0, [RotateSubcommand(0, 1.0, True)]) - - -@pytest.mark.asyncio -async def test_device_rotate_list(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", - 0, - {"RotateCmd": - {"FeatureCount": 1}})) - await dev.send_rotate_cmd([(1.0, True)]) - assert client.last_message == RotateCmd(0, [RotateSubcommand(0, 1.0, True)]) - - -@pytest.mark.asyncio -async def test_device_rotate_dict(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", - 0, - {"RotateCmd": - {"FeatureCount": 1}})) - await dev.send_rotate_cmd({0: (1.0, True)}) - assert client.last_message == RotateCmd(0, [RotateSubcommand(0, 1.0, True)]) - - -@pytest.mark.asyncio -async def test_device_linear_single_argument(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", - 0, - {"LinearCmd": - {"FeatureCount": 1}})) - await dev.send_linear_cmd((1000, 1.0)) - assert client.last_message == LinearCmd(0, [LinearSubcommand(0, 1000, 1.0)]) - - -@pytest.mark.asyncio -async def test_device_linear_list(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", - 0, - {"LinearCmd": - {"FeatureCount": 1}})) - await dev.send_linear_cmd([(1000, 1.0)]) - assert client.last_message == LinearCmd(0, [LinearSubcommand(0, 1000, 1.0)]) - - -@pytest.mark.asyncio -async def test_device_linear_dict(): - client = DummyClient() - dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", - 0, - {"LinearCmd": - {"FeatureCount": 1}})) - await dev.send_linear_cmd({0: (1000, 1.0)}) - assert client.last_message == LinearCmd(0, [LinearSubcommand(0, 1000, 1.0)]) diff --git a/tests/test_connector.py b/tests/test_connector.py new file mode 100644 index 0000000..495b58a --- /dev/null +++ b/tests/test_connector.py @@ -0,0 +1,111 @@ +"""Tests for connector and message sorter.""" + +import asyncio + +import pytest + +from buttplug._messages import Ok +from buttplug._utils.message_sorter import MessageSorter + + +class TestMessageSorter: + """Tests for MessageSorter request/response correlation.""" + + async def test_get_next_id_increments(self): + """get_next_id returns incrementing IDs.""" + sorter = MessageSorter() + + assert sorter.get_next_id() == 1 + assert sorter.get_next_id() == 2 + assert sorter.get_next_id() == 3 + + async def test_wait_and_resolve(self): + """wait_for_response resolves when resolve is called.""" + sorter = MessageSorter() + msg_id = sorter.get_next_id() + response = Ok(id=msg_id) + + # Start waiting in background + async def wait(): + return await sorter.wait_for_response(msg_id) + + wait_task = asyncio.create_task(wait()) + + # Give task time to start waiting + await asyncio.sleep(0.01) + + # Resolve the request + resolved = await sorter.resolve(msg_id, response) + assert resolved is True + + # Check the result + result = await wait_task + assert isinstance(result, Ok) + assert result.id == msg_id + + async def test_resolve_unknown_id(self): + """resolve returns False for unknown message IDs.""" + sorter = MessageSorter() + response = Ok(id=999) + + resolved = await sorter.resolve(999, response) + assert resolved is False + + async def test_timeout(self): + """wait_for_response raises TimeoutError on timeout.""" + sorter = MessageSorter() + msg_id = sorter.get_next_id() + + with pytest.raises(asyncio.TimeoutError): + await sorter.wait_for_response(msg_id, timeout=0.01) + + async def test_reject_all(self): + """reject_all cancels all pending requests.""" + sorter = MessageSorter() + msg_id1 = sorter.get_next_id() + msg_id2 = sorter.get_next_id() + + # Start two waits + task1 = asyncio.create_task(sorter.wait_for_response(msg_id1)) + task2 = asyncio.create_task(sorter.wait_for_response(msg_id2)) + + await asyncio.sleep(0.01) + + # Reject all + error = RuntimeError("Test error") + await sorter.reject_all(error) + + # Both should raise the error + with pytest.raises(RuntimeError, match="Test error"): + await task1 + + with pytest.raises(RuntimeError, match="Test error"): + await task2 + + async def test_pending_count(self): + """pending_count tracks number of pending requests.""" + sorter = MessageSorter() + + assert sorter.pending_count == 0 + + msg_id = sorter.get_next_id() + task = asyncio.create_task(sorter.wait_for_response(msg_id)) + await asyncio.sleep(0.01) + + assert sorter.pending_count == 1 + + await sorter.resolve(msg_id, Ok(id=msg_id)) + await task + + assert sorter.pending_count == 0 + + async def test_id_wraps_at_max(self): + """Message ID wraps around at protocol maximum.""" + sorter = MessageSorter() + sorter._next_id = 4294967295 # Max uint32 + + id1 = sorter.get_next_id() + id2 = sorter.get_next_id() + + assert id1 == 4294967295 + assert id2 == 1 # Wrapped back to 1 (not 0, which is reserved) diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..dbad5f7 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,282 @@ +"""Tests for device feature enumeration and capability checking.""" + +import pytest + +from buttplug._messages.device_info import ( + DeviceFeatureDefinition, + DeviceInfo, + FeatureInputDefinition, + FeatureOutputDefinition, +) +from buttplug.device import ButtplugDevice +from buttplug.enums import InputCommandType, InputType, OutputType +from buttplug.errors import ButtplugDeviceError +from buttplug.feature import DeviceFeature + + +class MockClient: + """Mock client for testing features without network.""" + + pass + + +class TestDeviceFeature: + """Tests for DeviceFeature capability checking.""" + + @pytest.fixture + def mock_client(self) -> MockClient: + """Mock client for feature testing.""" + return MockClient() + + @pytest.fixture + def vibrate_feature(self, mock_client: MockClient) -> DeviceFeature: + """Feature with vibration output.""" + defn = DeviceFeatureDefinition( + feature_index=0, + feature_description="Main Motor", + output={"Vibrate": FeatureOutputDefinition(value=(0, 20), duration=None)}, + input=None, + ) + return DeviceFeature(mock_client, 0, defn) # type: ignore[arg-type] + + @pytest.fixture + def battery_feature(self, mock_client: MockClient) -> DeviceFeature: + """Feature with battery input.""" + defn = DeviceFeatureDefinition( + feature_index=1, + feature_description="Battery", + output=None, + input={"Battery": FeatureInputDefinition(value=[(0, 100)], command=["Read"])}, + ) + return DeviceFeature(mock_client, 0, defn) # type: ignore[arg-type] + + @pytest.fixture + def position_feature(self, mock_client: MockClient) -> DeviceFeature: + """Feature with position output including duration.""" + defn = DeviceFeatureDefinition( + feature_index=0, + feature_description="Stroker", + output={ + "HwPositionWithDuration": FeatureOutputDefinition( + value=(0, 100), duration=(0, 1000) + ) + }, + input=None, + ) + return DeviceFeature(mock_client, 0, defn) # type: ignore[arg-type] + + def test_feature_index(self, vibrate_feature: DeviceFeature) -> None: + """Feature exposes index.""" + assert vibrate_feature.index == 0 + + def test_feature_description(self, vibrate_feature: DeviceFeature) -> None: + """Feature exposes description.""" + assert vibrate_feature.description == "Main Motor" + + def test_has_output_true(self, vibrate_feature: DeviceFeature) -> None: + """has_output returns True for supported output.""" + assert vibrate_feature.has_output(OutputType.VIBRATE) is True + assert vibrate_feature.has_output("Vibrate") is True + + def test_has_output_false(self, vibrate_feature: DeviceFeature) -> None: + """has_output returns False for unsupported output.""" + assert vibrate_feature.has_output(OutputType.ROTATE) is False + + def test_has_input_true(self, battery_feature: DeviceFeature) -> None: + """has_input returns True for supported input.""" + assert battery_feature.has_input(InputType.BATTERY) is True + assert battery_feature.has_input("Battery") is True + + def test_has_input_false(self, battery_feature: DeviceFeature) -> None: + """has_input returns False for unsupported input.""" + assert battery_feature.has_input(InputType.RSSI) is False + + def test_supports_input_command(self, battery_feature: DeviceFeature) -> None: + """supports_input_command checks command support.""" + assert ( + battery_feature.supports_input_command(InputType.BATTERY, InputCommandType.READ) is True + ) + assert ( + battery_feature.supports_input_command(InputType.BATTERY, InputCommandType.SUBSCRIBE) + is False + ) + + def test_step_range(self, vibrate_feature: DeviceFeature) -> None: + """step_range returns value range.""" + range_val = vibrate_feature.step_range(OutputType.VIBRATE) + assert range_val == (0, 20) + + def test_step_range_not_found(self, vibrate_feature: DeviceFeature) -> None: + """step_range returns None for unsupported output.""" + assert vibrate_feature.step_range(OutputType.ROTATE) is None + + def test_step_count(self, vibrate_feature: DeviceFeature) -> None: + """step_count returns max step value.""" + assert vibrate_feature.step_count(OutputType.VIBRATE) == 20 + + def test_step_count_not_found(self, vibrate_feature: DeviceFeature) -> None: + """step_count returns None for unsupported output.""" + assert vibrate_feature.step_count(OutputType.ROTATE) is None + + def test_duration_range(self, position_feature: DeviceFeature) -> None: + """duration_range returns duration range.""" + duration = position_feature.duration_range(OutputType.POSITION_WITH_DURATION) + assert duration == (0, 1000) + + def test_duration_range_not_found(self, vibrate_feature: DeviceFeature) -> None: + """duration_range returns None when no duration.""" + assert vibrate_feature.duration_range(OutputType.VIBRATE) is None + + def test_convert_to_step_float(self, vibrate_feature: DeviceFeature) -> None: + """convert_to_step converts float percent to steps.""" + # 0.5 * 20 = 10, ceil(10) = 10 + assert vibrate_feature.convert_to_step(OutputType.VIBRATE, 0.5) == 10 + # 0.0 * 20 = 0 + assert vibrate_feature.convert_to_step(OutputType.VIBRATE, 0.0) == 0 + # 1.0 * 20 = 20 + assert vibrate_feature.convert_to_step(OutputType.VIBRATE, 1.0) == 20 + # 0.05 * 20 = 1, ceil(1) = 1 + assert vibrate_feature.convert_to_step(OutputType.VIBRATE, 0.05) == 1 + + def test_convert_to_step_int(self, vibrate_feature: DeviceFeature) -> None: + """convert_to_step passes through int step values.""" + assert vibrate_feature.convert_to_step(OutputType.VIBRATE, 10) == 10 + assert vibrate_feature.convert_to_step(OutputType.VIBRATE, 0) == 0 + assert vibrate_feature.convert_to_step(OutputType.VIBRATE, 20) == 20 + + def test_convert_to_step_out_of_range_float(self, vibrate_feature: DeviceFeature) -> None: + """convert_to_step raises for float out of range.""" + with pytest.raises(ButtplugDeviceError, match="must be between"): + vibrate_feature.convert_to_step(OutputType.VIBRATE, 1.5) + + def test_convert_to_step_out_of_range_int(self, vibrate_feature: DeviceFeature) -> None: + """convert_to_step raises for int out of range.""" + with pytest.raises(ButtplugDeviceError, match="out of range"): + vibrate_feature.convert_to_step(OutputType.VIBRATE, 25) + + +class TestButtplugDevice: + """Tests for ButtplugDevice capability checking (no client interaction).""" + + @pytest.fixture + def mock_client(self) -> MockClient: + """Mock client for device testing.""" + return MockClient() + + @pytest.fixture + def multi_feature_device_info(self) -> DeviceInfo: + """Device info with multiple features.""" + return DeviceInfo( + device_name="Test Multi-Feature Device", + device_index=0, + device_message_timing_gap=50, + device_display_name="My Device", + device_features={ + 0: DeviceFeatureDefinition( + feature_index=0, + feature_description="Vibrator 1", + output={"Vibrate": FeatureOutputDefinition(value=(0, 20), duration=None)}, + input=None, + ), + 1: DeviceFeatureDefinition( + feature_index=1, + feature_description="Vibrator 2", + output={"Vibrate": FeatureOutputDefinition(value=(0, 20), duration=None)}, + input=None, + ), + 2: DeviceFeatureDefinition( + feature_index=2, + feature_description="Rotator", + output={"Rotate": FeatureOutputDefinition(value=(0, 10), duration=None)}, + input=None, + ), + 3: DeviceFeatureDefinition( + feature_index=3, + feature_description="Battery", + output=None, + input={"Battery": FeatureInputDefinition(value=[(0, 100)], command=["Read"])}, + ), + }, + ) + + def test_device_properties( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """Device exposes basic properties.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + + assert device.index == 0 + assert device.name == "Test Multi-Feature Device" + assert device.display_name == "My Device" + assert device.message_timing_gap == 50 + assert len(device.features) == 4 + + def test_has_output( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """Device has_output checks all features.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + + assert device.has_output(OutputType.VIBRATE) is True + assert device.has_output(OutputType.ROTATE) is True + assert device.has_output(OutputType.POSITION) is False + + def test_has_input( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """Device has_input checks all features.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + + assert device.has_input(InputType.BATTERY) is True + assert device.has_input(InputType.RSSI) is False + + def test_get_features_with_output( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """get_features_with_output returns matching features.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + + vibrate_features = device.get_features_with_output(OutputType.VIBRATE) + assert len(vibrate_features) == 2 + + rotate_features = device.get_features_with_output(OutputType.ROTATE) + assert len(rotate_features) == 1 + + position_features = device.get_features_with_output(OutputType.POSITION) + assert len(position_features) == 0 + + def test_get_features_with_input( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """get_features_with_input returns matching features.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + + battery_features = device.get_features_with_input(InputType.BATTERY) + assert len(battery_features) == 1 + + rssi_features = device.get_features_with_input(InputType.RSSI) + assert len(rssi_features) == 0 + + def test_has_battery( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """has_battery checks for battery sensor.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + assert device.has_battery() is True + + def test_has_rssi(self, mock_client: MockClient, multi_feature_device_info: DeviceInfo) -> None: + """has_rssi checks for RSSI sensor.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + assert device.has_rssi() is False + + def test_feature_step_count( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """Features expose step count for precision control.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + + vibrate_feature = device.features[0] + assert vibrate_feature.step_count(OutputType.VIBRATE) == 20 + + rotate_feature = device.features[2] + assert rotate_feature.step_count(OutputType.ROTATE) == 10 diff --git a/tests/test_messages.py b/tests/test_messages.py index 31bc0d6..6cc5139 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -1,74 +1,310 @@ -import unittest -from buttplug.core import (ButtplugMessage, Ok, Error, ButtplugErrorCode, - Test, DeviceAdded, MessageAttributes, DeviceRemoved, - DeviceInfo, DeviceList, VibrateCmd, SpeedSubcommand, - RotateCmd, RotateSubcommand, LinearCmd, - LinearSubcommand) - - -class TestMessages(unittest.TestCase): - - def run_msg_test(self, msg_obj, msg_json): - msg_obj.id = ButtplugMessage.DEFAULT_ID - assert msg_obj.as_json() == msg_json - assert ButtplugMessage.from_json(msg_json) == msg_obj - - def test_message_ok(self): - ok = Ok() - json_msg = "{\"Ok\": {\"Id\": 1}}" - self.run_msg_test(ok, json_msg) - - def test_message_error(self): - error = Error("Test", ButtplugErrorCode.ERROR_MSG) - json_msg = "{\"Error\": {\"ErrorMessage\": \"Test\", \"ErrorCode\": 3, \"Id\": 1}}" - self.run_msg_test(error, json_msg) - - def test_message_test(self): - test = Test("Test") - json_msg = "{\"Test\": {\"TestString\": \"Test\", \"Id\": 1}}" - self.run_msg_test(test, json_msg) - - def test_device_added(self): - device_added = DeviceAdded("Test Device", - 1, - {"VibrateCmd": {"FeatureCount": 1}}) - json_msg = "{\"DeviceAdded\": {\"DeviceName\": \"Test Device\", \"DeviceIndex\": 1, \"DeviceMessages\": {\"VibrateCmd\": {\"FeatureCount\": 1}}, \"Id\": 1}}" - self.run_msg_test(device_added, json_msg) - - def test_device_removed(self): - device_removed = DeviceRemoved(1) - json_msg = "{\"DeviceRemoved\": {\"DeviceIndex\": 1, \"Id\": 1}}" - self.run_msg_test(device_removed, json_msg) - - def test_device_list(self): - device_list = DeviceList([DeviceInfo("TestDevice1", - 0, - {"SingleMotorVibrateCmd": {}, - "VibrateCmd": {"FeatureCount": 2}, - "StopDeviceCmd": {}, - }), - DeviceInfo("TestDevice2", - 1, - {"FleshlightLaunchFW12Cmd": {}, - "LinearCmd": {"FeatureCount": 1}, - "StopDeviceCmd": {}})]) - json_msg = "{\"DeviceList\": {\"Devices\": [{\"DeviceName\": \"TestDevice1\", \"DeviceIndex\": 0, \"DeviceMessages\": {\"SingleMotorVibrateCmd\": {}, \"VibrateCmd\": {\"FeatureCount\": 2}, \"StopDeviceCmd\": {}}}, {\"DeviceName\": \"TestDevice2\", \"DeviceIndex\": 1, \"DeviceMessages\": {\"FleshlightLaunchFW12Cmd\": {}, \"LinearCmd\": {\"FeatureCount\": 1}, \"StopDeviceCmd\": {}}}], \"Id\": 1}}" - self.run_msg_test(device_list, json_msg) - - def test_vibrate_cmd(self): - vibrate_cmd = VibrateCmd(0, [SpeedSubcommand(0, 0), - SpeedSubcommand(1, 0.5)]) - json_msg = "{\"VibrateCmd\": {\"DeviceIndex\": 0, \"Speeds\": [{\"Index\": 0, \"Speed\": 0}, {\"Index\": 1, \"Speed\": 0.5}], \"Id\": 1}}" - self.run_msg_test(vibrate_cmd, json_msg) - - def test_rotate_cmd(self): - rotate_cmd = RotateCmd(0, [RotateSubcommand(0, 0, False), - RotateSubcommand(1, 0.5, True)]) - json_msg = "{\"RotateCmd\": {\"DeviceIndex\": 0, \"Rotations\": [{\"Index\": 0, \"Speed\": 0, \"Clockwise\": false}, {\"Index\": 1, \"Speed\": 0.5, \"Clockwise\": true}], \"Id\": 1}}" - self.run_msg_test(rotate_cmd, json_msg) - - def test_linear_cmd(self): - linear_cmd = LinearCmd(0, [LinearSubcommand(0, 100, 1.0), - LinearSubcommand(1, 500, 0.5)]) - json_msg = "{\"LinearCmd\": {\"DeviceIndex\": 0, \"Vectors\": [{\"Index\": 0, \"Duration\": 100, \"Position\": 1.0}, {\"Index\": 1, \"Duration\": 500, \"Position\": 0.5}], \"Id\": 1}}" - self.run_msg_test(linear_cmd, json_msg) +"""Tests for message serialization and deserialization.""" + +import json + +import pytest + +from buttplug._messages import ( + DeviceList, + Error, + InputCmd, + InputReading, + Ok, + OutputCmd, + Ping, + RequestDeviceList, + RequestServerInfo, + ScanningFinished, + ServerInfo, + StartScanning, + StopCmd, + StopScanning, +) +from buttplug._messages.base import parse_message, parse_messages +from buttplug.enums import ErrorCode + + +class TestHandshakeMessages: + """Tests for handshake and status messages.""" + + def test_request_server_info_serialize(self): + """RequestServerInfo serializes with PascalCase.""" + msg = RequestServerInfo(id=1, client_name="Test Client") + result = msg.to_protocol() + + assert result == [ + { + "RequestServerInfo": { + "Id": 1, + "ClientName": "Test Client", + "ProtocolVersionMajor": 4, + "ProtocolVersionMinor": 0, + } + } + ] + + def test_request_server_info_deserialize(self): + """RequestServerInfo deserializes from PascalCase.""" + data = { + "RequestServerInfo": { + "Id": 1, + "ClientName": "My App", + "ProtocolVersionMajor": 4, + "ProtocolVersionMinor": 0, + } + } + msg = parse_message(data) + + assert isinstance(msg, RequestServerInfo) + assert msg.id == 1 + assert msg.client_name == "My App" + assert msg.protocol_version_major == 4 + + def test_server_info_deserialize(self): + """ServerInfo deserializes from protocol format.""" + data = { + "ServerInfo": { + "Id": 1, + "ServerName": "Intiface Central", + "MaxPingTime": 1000, + "ProtocolVersionMajor": 4, + "ProtocolVersionMinor": 0, + } + } + msg = parse_message(data) + + assert isinstance(msg, ServerInfo) + assert msg.server_name == "Intiface Central" + assert msg.max_ping_time == 1000 + + def test_ok_serialize(self): + """Ok serializes correctly.""" + msg = Ok(id=5) + result = msg.to_protocol() + + assert result == [{"Ok": {"Id": 5}}] + + def test_error_deserialize(self): + """Error deserializes with error code.""" + data = { + "Error": { + "Id": 0, + "ErrorMessage": "Ping timeout", + "ErrorCode": 2, + } + } + msg = parse_message(data) + + assert isinstance(msg, Error) + assert msg.error_message == "Ping timeout" + assert msg.error_code == ErrorCode.PING + + def test_ping_roundtrip(self): + """Ping message round-trips correctly.""" + msg = Ping(id=10) + protocol = msg.to_protocol() + parsed = parse_message(protocol[0]) + + assert isinstance(parsed, Ping) + assert parsed.id == 10 + + +class TestScanningMessages: + """Tests for scanning control messages.""" + + def test_start_scanning_serialize(self): + """StartScanning serializes correctly.""" + msg = StartScanning(id=2) + result = msg.to_protocol() + + assert result == [{"StartScanning": {"Id": 2}}] + + def test_stop_scanning_serialize(self): + """StopScanning serializes correctly.""" + msg = StopScanning(id=3) + result = msg.to_protocol() + + assert result == [{"StopScanning": {"Id": 3}}] + + def test_scanning_finished_deserialize(self): + """ScanningFinished deserializes correctly.""" + data = {"ScanningFinished": {"Id": 0}} + msg = parse_message(data) + + assert isinstance(msg, ScanningFinished) + assert msg.id == 0 + + def test_request_device_list_serialize(self): + """RequestDeviceList serializes correctly.""" + msg = RequestDeviceList(id=4) + result = msg.to_protocol() + + assert result == [{"RequestDeviceList": {"Id": 4}}] + + +class TestDeviceListMessage: + """Tests for DeviceList message parsing.""" + + def test_device_list_deserialize(self, sample_device_list_data): + """DeviceList deserializes complex device structure.""" + data = {"DeviceList": sample_device_list_data} + msg = parse_message(data) + + assert isinstance(msg, DeviceList) + assert msg.id == 1 + assert len(msg.devices) == 2 + + # Check first device + device0 = msg.devices[0] + assert device0.device_name == "Test Vibrator" + assert device0.device_index == 0 + assert device0.device_message_timing_gap == 50 + assert device0.device_display_name == "My Vibrator" + assert len(device0.device_features) == 3 + + # Check feature with output + feature0 = device0.device_features[0] + assert feature0.feature_index == 0 + assert feature0.feature_description == "Clitoral Stimulator" + assert feature0.output is not None + assert "Vibrate" in feature0.output + assert feature0.output["Vibrate"].value == (0, 20) + + # Check feature with input + feature2 = device0.device_features[2] + assert feature2.input is not None + assert "Battery" in feature2.input + battery_input = feature2.input["Battery"] + assert battery_input.value == [(0, 100)] + assert battery_input.command == ["Read"] + + # Check second device (stroker with position) + device1 = msg.devices[1] + assert device1.device_name == "Test Stroker" + feature = device1.device_features[0] + assert "HwPositionWithDuration" in feature.output + pos_output = feature.output["HwPositionWithDuration"] + assert pos_output.value == (0, 100) + assert pos_output.duration == (0, 1000) + + def test_device_list_empty(self): + """DeviceList handles empty device dict.""" + data = {"DeviceList": {"Id": 1, "Devices": {}}} + msg = parse_message(data) + + assert isinstance(msg, DeviceList) + assert len(msg.devices) == 0 + + +class TestCommandMessages: + """Tests for device command messages.""" + + def test_output_cmd_vibrate_serialize(self): + """OutputCmd for vibrate serializes correctly.""" + msg = OutputCmd(id=5, device_index=0, feature_index=0, command={"Vibrate": {"Value": 10}}) + result = msg.to_protocol() + + assert result == [ + { + "OutputCmd": { + "Id": 5, + "DeviceIndex": 0, + "FeatureIndex": 0, + "Command": {"Vibrate": {"Value": 10}}, + } + } + ] + + def test_output_cmd_position_with_duration(self): + """OutputCmd for position with duration serializes correctly.""" + msg = OutputCmd( + id=6, + device_index=1, + feature_index=0, + command={"HwPositionWithDuration": {"Value": 80, "Duration": 250}}, + ) + result = msg.to_protocol() + + expected_cmd = result[0]["OutputCmd"]["Command"] + assert expected_cmd["HwPositionWithDuration"]["Value"] == 80 + assert expected_cmd["HwPositionWithDuration"]["Duration"] == 250 + + def test_input_cmd_serialize(self): + """InputCmd serializes correctly.""" + msg = InputCmd(id=7, device_index=0, feature_index=2, input_type="Battery", command="Read") + result = msg.to_protocol() + + assert result == [ + { + "InputCmd": { + "Id": 7, + "DeviceIndex": 0, + "FeatureIndex": 2, + "Type": "Battery", + "Command": "Read", + } + } + ] + + def test_input_reading_deserialize(self, sample_input_reading_data): + """InputReading deserializes correctly.""" + data = {"InputReading": sample_input_reading_data} + msg = parse_message(data) + + assert isinstance(msg, InputReading) + assert msg.device_index == 0 + assert msg.feature_index == 2 + assert "Battery" in msg.reading + assert msg.reading["Battery"].value == 75 + + def test_stop_cmd_all_devices(self): + """StopCmd for all devices serializes correctly.""" + msg = StopCmd(id=8) + result = msg.to_protocol() + + assert result == [{"StopCmd": {"Id": 8, "Inputs": True, "Outputs": True}}] + + def test_stop_cmd_specific_device(self): + """StopCmd for specific device serializes correctly.""" + msg = StopCmd(id=9, device_index=0, outputs=True, inputs=False) + result = msg.to_protocol() + + assert result == [ + {"StopCmd": {"Id": 9, "DeviceIndex": 0, "Inputs": False, "Outputs": True}} + ] + + +class TestMessageParsing: + """Tests for message parsing utilities.""" + + def test_parse_messages_array(self): + """parse_messages handles array of messages.""" + data = [ + {"Ok": {"Id": 1}}, + {"ScanningFinished": {"Id": 0}}, + ] + messages = parse_messages(data) + + assert len(messages) == 2 + assert isinstance(messages[0], Ok) + assert isinstance(messages[1], ScanningFinished) + + def test_parse_message_unknown_type(self): + """parse_message raises for unknown message type.""" + with pytest.raises(ValueError, match="Unknown message type"): + parse_message({"UnknownType": {"Id": 1}}) + + def test_parse_message_multiple_types(self): + """parse_message raises for multiple types in one dict.""" + with pytest.raises(ValueError, match="Expected single message type"): + parse_message({"Ok": {"Id": 1}, "Error": {"Id": 1}}) + + def test_json_roundtrip(self): + """Messages survive JSON serialization roundtrip.""" + msg = RequestServerInfo(id=1, client_name="Test") + json_str = json.dumps(msg.to_protocol()) + parsed_data = json.loads(json_str) + restored = parse_message(parsed_data[0]) + + assert isinstance(restored, RequestServerInfo) + assert restored.client_name == "Test" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f1b7429 --- /dev/null +++ b/uv.lock @@ -0,0 +1,593 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "buttplug" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1" }, + { name = "websockets", specifier = ">=12.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.14.14" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "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 = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + +[[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 = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]