From 994695724ddf203f27bd148b52f551081be1a8c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Jun 2021 17:54:07 +0000 Subject: [PATCH 01/18] build(deps): bump websockets from 7.0 to 9.1 Bumps [websockets](https://github.com/aaugustin/websockets) from 7.0 to 9.1. - [Release notes](https://github.com/aaugustin/websockets/releases) - [Changelog](https://github.com/aaugustin/websockets/blob/9.1/docs/changelog.rst) - [Commits](https://github.com/aaugustin/websockets/compare/7.0...9.1) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index f29841c..5921107 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,7 @@ verify_ssl = true [dev-packages] [packages] -websockets = "==7.0" +websockets = "==9.1" [requires] python_version = "3.8" From 7b70aaa48cf348884d7cb18982604003eaaf557d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 12 Dec 2021 21:22:04 -0800 Subject: [PATCH 02/18] chore: Move from print statements to logging --- buttplug/client/client.py | 5 +++-- buttplug/client/websocket_connector.py | 9 +++++---- examples/example.py | 16 ++++++++++------ tests/test_client_device.py | 3 ++- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/buttplug/client/client.py b/buttplug/client/client.py index 00f1a6a..19600e0 100644 --- a/buttplug/client/client.py +++ b/buttplug/client/client.py @@ -19,6 +19,7 @@ from ..utils import EventHandler from typing import Dict, List, Tuple, Union from asyncio import Future, get_event_loop +import logging, sys class ButtplugClient(ButtplugClientConnectorObserver): @@ -89,7 +90,7 @@ 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) + logging.info("Connected to server: " + msg.server_name) dl: DeviceList = await self._send_message_expect_reply(RequestDeviceList(), DeviceList) self._handle_device_list(dl) @@ -218,7 +219,7 @@ def __init__(self, client: ButtplugClient, device_msg: Union[DeviceInfo, self.name = device_info.device_name self._index = device_info.device_index self.allowed_messages: Dict[str, MessageAttributes] = {} - print(device_info.device_messages) + logging.debug(device_info.device_messages) for (msg_name, attrs) in device_info.device_messages.items(): self.allowed_messages[msg_name] = MessageAttributes(attrs.get("FeatureCount")) else: diff --git a/buttplug/client/websocket_connector.py b/buttplug/client/websocket_connector.py index 07db738..05af7bf 100644 --- a/buttplug/client/websocket_connector.py +++ b/buttplug/client/websocket_connector.py @@ -4,6 +4,7 @@ import asyncio import json from typing import Optional +import logging class ButtplugClientWebsocketConnector(ButtplugClientConnector): @@ -27,19 +28,19 @@ async def _consumer_handler(self): try: message = await self.ws.recv() except Exception as e: - print("Exiting read loop") - print(e) + logging.error("Exiting read loop") + logging.error(e) break msg_array = json.loads(message) for msg in msg_array: bp_msg = ButtplugMessage.from_dict(msg) - print(bp_msg) + logging.debug(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) + logging.debug(msg_str) await self.ws.send(msg_str) async def disconnect(self): diff --git a/examples/example.py b/examples/example.py index 7287b1e..f7fa03c 100644 --- a/examples/example.py +++ b/examples/example.py @@ -23,10 +23,11 @@ ButtplugClientDevice, ButtplugClientConnectorError) from buttplug.core import ButtplugLogLevel import asyncio - +import logging +import sys async def cancel_me(): - print('cancel_me(): before sleep') + logging.debug('cancel_me(): before sleep') try: await asyncio.sleep(3600) @@ -39,7 +40,7 @@ async def device_added_task(dev: ButtplugClientDevice): # # First off, we'll print the name of the devices. - print("Device Added: {}".format(dev.name)) + logging.info("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 @@ -83,7 +84,7 @@ def device_added(emitter, dev: ButtplugClientDevice): asyncio.create_task(device_added_task(dev)) def device_removed(emitter, dev: ButtplugClientDevice): - print("Device removed: ", dev) + logging.info("Device removed: ", dev) async def main(): # And now we're in the main function. @@ -128,7 +129,7 @@ async def main(): try: await client.connect(connector) except ButtplugClientConnectorError as e: - print("Could not connect to server, exiting: {}".format(e.message)) + logging.error("Could not connect to server, exiting: {}".format(e.message)) return # If this succeeds, we'll be connected. If not, we'll probably have some @@ -165,11 +166,14 @@ async def main(): # Now that we've done that, we just disconnect and we're done! await client.disconnect() - print("Disconnected, quitting") + logging.info("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. +# +# But first, set up logging +logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) asyncio.run(main(), debug=True) diff --git a/tests/test_client_device.py b/tests/test_client_device.py index a5d2e71..ce10c13 100644 --- a/tests/test_client_device.py +++ b/tests/test_client_device.py @@ -1,5 +1,6 @@ import unittest import pytest +import logging from buttplug.core import (ButtplugMessage, Ok, Error, ButtplugErrorCode, Test, DeviceAdded, MessageAttributes, DeviceRemoved, DeviceInfo, DeviceList, VibrateCmd, SpeedSubcommand, @@ -13,7 +14,7 @@ def __init__(self): self.last_message: ButtplugMessage = None async def _send_message_expect_ok(self, msg: ButtplugMessage): - print("Got message") + logging.debug("Got message") self.last_message = msg From 9a3b6521c69d6fd4332009450b09761e81dd9137 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 12 Dec 2021 21:22:15 -0800 Subject: [PATCH 03/18] build: Update to latest websockets --- requirements.txt | 3 +-- setup.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index fe0ddac..9ecc266 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -pkg-resources==0.0.0 -websockets==7.0 +websockets==10.1 diff --git a/setup.py b/setup.py index 285def9..25ed4b4 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", ], - install_requires=['websockets>=7.0', ], + install_requires=['websockets>=10', ], packages=find_packages(exclude=["docs", "*.tests", "*.tests.*", "tests.*", "tests"])) From 71968896f745adb2db0cb96c9a8ad692f8b17b7a Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 12 Dec 2021 21:24:44 -0800 Subject: [PATCH 04/18] doc: Dial back deprecations warnings in the readme Like, it still basically works fine and doing the python move to FFI is going to suck. --- README.md | 53 +++++++++++++++++++++++------------------------------ 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 9772411..acdffef 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ -# DEPRECATION WARNING +# DEPRECATION (at some point) 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. +This project will be deprecated and archived at some point in the future (still not sure when that +is), 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"). +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. +You may continue to use this repo for the time being (so far it's been a year and I've gotten +nowhere on that upgrade, and this library still works fine!), just wanted everyone to be aware of +the changes happening in the near future. # buttplug-py @@ -22,25 +21,21 @@ aware of the changes happening in the near future. [![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 +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). -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. +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. -For more information on the Buttplug project, check out the project -website at [https://buttplug.io](https://buttplug.io). +For more information on the Buttplug project, check out the project website at +[https://buttplug.io](https://buttplug.io). ## Table Of Contents @@ -51,9 +46,8 @@ website at [https://buttplug.io](https://buttplug.io). ## Support The Project -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! +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! ## Documentation @@ -68,10 +62,9 @@ Other recommended reading includes ## Examples -Example code is available in the examples/ directory. Examples are -heavily commented to hopefully make usage of the library clearer. +Example code is available in the examples/ directory. Examples are heavily commented to hopefully +make usage of the library clearer. ## License -Buttplug is BSD 3-Clause licensed. More information is available in -the LICENSE file. +Buttplug is BSD 3-Clause licensed. More information is available in the LICENSE file. From a3d3fbc25010b8d1fd4d81a9d67a39e7391627eb Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 12 Dec 2021 21:26:05 -0800 Subject: [PATCH 05/18] doc: Update version/CHANGELOG for 0.2.1 --- CHANGELOG.md | 7 +++++++ buttplug/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7436abd..4d9b9f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 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/buttplug/__init__.py b/buttplug/__init__.py index 4e1273d..926c6a8 100644 --- a/buttplug/__init__.py +++ b/buttplug/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.2.0" +__version__ = "0.2.1" name = "buttplug" From ad1972e23143beb5fcb67586f1612d264f87fb17 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 12 Dec 2021 21:30:27 -0800 Subject: [PATCH 06/18] doc: more README updates C#/node are dead, thank fuck --- README.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index acdffef..d455ea1 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,7 @@ the changes happening in the near future. 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). - -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. +as the [Intiface Desktop Application](https://github.com/intiface/intiface-desktop) or [Intiface Rust CLI](https://github.com/intiface/intiface-cli-rs). For more information on the Buttplug project, check out the project website at [https://buttplug.io](https://buttplug.io). @@ -46,8 +40,9 @@ For more information on the Buttplug project, check out the project website at ## Support The Project -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! +If you find this project helpful, you can [support us via Patreon](http://patreon.com/qdot) or +[Github Sponsors](http://github.com/sponsors/qdot)! Every donation helps us afford more hardware to +reverse, document, and write code for! ## Documentation From 3b8195975e17ec634b2f510513f04c81446532de Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 3 Jul 2022 15:53:17 -0700 Subject: [PATCH 07/18] doc: Undeprecate --- README.md | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d455ea1..8785165 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,3 @@ -# DEPRECATION (at some point) WARNING - -This project will be deprecated and archived at some point in the future (still not sure when that -is), 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 (so far it's been a year and I've gotten -nowhere on that upgrade, and this library still works fine!), 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) @@ -31,6 +14,10 @@ as the [Intiface Desktop Application](https://github.com/intiface/intiface-deskt For more information on the Buttplug project, check out the project website at [https://buttplug.io](https://buttplug.io). +## NO LONGER DEPRECATED + +If you're thinking "but wasn't buttplug-py deprecated?"... Yeah it was but honestly it works and converting to the new rust FFI is going to take a while and it hasn't been on my todo list in well over a year, so we'll actually just call this still in production for now. + ## Table Of Contents - [Support The Project](#support-the-project) From 080c1a9dda597eb6fc172cdc3a27fd20fba57b74 Mon Sep 17 00:00:00 2001 From: DevelopmentalOctopus Date: Sun, 3 Jul 2022 17:15:07 -0600 Subject: [PATCH 08/18] device_removed_handlers get device device_removed_handlers now correctly receive the removed ButtplugClientDevice rather than its integer id --- CHANGELOG.md | 6 ++++++ buttplug/__init__.py | 2 +- buttplug/client/client.py | 3 ++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9b9f2..8a44f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.2.2 (2022-07-03) + +## Bug Fixes + +- device_removed_handlers now correctly receive the removed ButtplugClientDevice rather than its integer id. + # 0.2.1 (2021-12-12) ## Bug Fixes diff --git a/buttplug/__init__.py b/buttplug/__init__.py index 926c6a8..725047f 100644 --- a/buttplug/__init__.py +++ b/buttplug/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.2.1" +__version__ = "0.2.2" name = "buttplug" diff --git a/buttplug/client/client.py b/buttplug/client/client.py index 19600e0..2d5e7a3 100644 --- a/buttplug/client/client.py +++ b/buttplug/client/client.py @@ -142,8 +142,9 @@ async def _parse_message(self, msg: ButtplugMessage): self.device_added_handler(self.devices[da.device_index]) elif isinstance(msg, DeviceRemoved): dr: DeviceRemoved = msg + removed_device: ButtplugClientDevice = self.devices[dr.device_index] self.devices.pop(dr.device_index) - self.device_removed_handler(dr.device_index) + self.device_removed_handler(removed_device) elif isinstance(msg, ScanningFinished): self.scanning_finished_handler() elif isinstance(msg, Log): From d95e70261fc9736f963cdf6406e00e9d874da372 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 6 Aug 2022 11:33:35 -0700 Subject: [PATCH 09/18] doc: Update to v0.3 Event handler API change is a breaking exposed API change, so need to up the version --- CHANGELOG.md | 4 ++-- Pipfile | 2 +- buttplug/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a44f3f..df78d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ -# 0.2.2 (2022-07-03) +# 0.3.0 (2022-08-06) -## Bug Fixes +## Breaking Changes - device_removed_handlers now correctly receive the removed ButtplugClientDevice rather than its integer id. diff --git a/Pipfile b/Pipfile index 5921107..b2735a9 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,7 @@ verify_ssl = true [dev-packages] [packages] -websockets = "==9.1" +websockets = "==10.1" [requires] python_version = "3.8" diff --git a/buttplug/__init__.py b/buttplug/__init__.py index 725047f..f11a725 100644 --- a/buttplug/__init__.py +++ b/buttplug/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.2.2" +__version__ = "0.3.0" name = "buttplug" From d1473c7ab3c9c687f994774285a6444fa56c79f1 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 18 Dec 2022 12:15:21 -0800 Subject: [PATCH 10/18] chore: Update README about communit library --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8785165..f7b3e94 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# buttplug-py +# buttplug-py (DEPRECATED) [![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) @@ -7,6 +7,10 @@ [![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) +THIS VERSION OF BUTTPLUG-PY IS DEPRECATED IN FAVOR OF THE [COMMUNITY MAINTAINED LIBRARY](https://github.com/Siege-Wizard/buttplug-py). The community maintained library brings in support for Buttplug Spec v3, as well as many nice to have features. It is recommended to use that instead of this library. + +## Old Intro + 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 Rust CLI](https://github.com/intiface/intiface-cli-rs). @@ -14,10 +18,6 @@ as the [Intiface Desktop Application](https://github.com/intiface/intiface-deskt For more information on the Buttplug project, check out the project website at [https://buttplug.io](https://buttplug.io). -## NO LONGER DEPRECATED - -If you're thinking "but wasn't buttplug-py deprecated?"... Yeah it was but honestly it works and converting to the new rust FFI is going to take a while and it hasn't been on my todo list in well over a year, so we'll actually just call this still in production for now. - ## Table Of Contents - [Support The Project](#support-the-project) From e7dbd413a97d327c22f957a14b3110ba66bd5a0b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 18 Dec 2022 12:15:59 -0800 Subject: [PATCH 11/18] doc: Make header message larger --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7b3e94..bb64955 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ [![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) -THIS VERSION OF BUTTPLUG-PY IS DEPRECATED IN FAVOR OF THE [COMMUNITY MAINTAINED LIBRARY](https://github.com/Siege-Wizard/buttplug-py). The community maintained library brings in support for Buttplug Spec v3, as well as many nice to have features. It is recommended to use that instead of this library. +### THIS VERSION OF BUTTPLUG-PY IS DEPRECATED IN FAVOR OF THE [COMMUNITY MAINTAINED LIBRARY](https://github.com/Siege-Wizard/buttplug-py). + +The community maintained library brings in support for Buttplug Spec v3, as well as many nice to have features. It is recommended to use that instead of this library. ## Old Intro From cd7f629dfaabaca2512a237aaec189b90a611afc Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 1 Jan 2023 17:41:48 -0800 Subject: [PATCH 12/18] Change to Awesome list link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bb64955..8826d47 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![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) -### THIS VERSION OF BUTTPLUG-PY IS DEPRECATED IN FAVOR OF THE [COMMUNITY MAINTAINED LIBRARY](https://github.com/Siege-Wizard/buttplug-py). +### THIS VERSION OF BUTTPLUG-PY IS DEPRECATED IN FAVOR OF [COMMUNITY MAINTAINED LIBRARIES](https://github.com/buttplugio/awesome-buttplug#python). The community maintained library brings in support for Buttplug Spec v3, as well as many nice to have features. It is recommended to use that instead of this library. From 7e6fe77553a66af566947cf624ba24c98256bb8b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 25 Jan 2026 20:30:36 -0800 Subject: [PATCH 13/18] feat: Wipe the slate clean, update to spec v4 This repo hasn't been touched in 3 years so might as well over Or let the robot start it over, as it were --- .github/workflows/ci.yml | 40 ++ Pipfile | 12 - README.md | 134 ++++-- buttplug/__init__.py | 2 - buttplug/client/__init__.py | 7 - buttplug/client/client.py | 361 --------------- buttplug/client/connector.py | 52 --- buttplug/client/websocket_connector.py | 48 -- buttplug/core/__init__.py | 12 - buttplug/core/enums.py | 19 - buttplug/core/errors.py | 60 --- buttplug/core/messages.py | 314 ------------- buttplug/utils/__init__.py | 3 - buttplug/utils/eventhandler.py | 55 --- docs/Makefile | 20 - docs/_static/js/matomo.js | 12 - docs/client.rst | 5 - docs/conf.py | 64 --- docs/device.rst | 5 - docs/enums.rst | 8 - docs/errors.rst | 8 - docs/eventhandler.rst | 5 - docs/index.md | 52 --- docs/index.rst | 31 -- docs/make.bat | 35 -- docs/requirements.txt | 3 - examples/connection.py | 43 ++ examples/device_control.py | 98 ++++ examples/device_enumeration.py | 61 +++ examples/device_info.py | 79 ++++ examples/errors.py | 89 ++++ examples/example.py | 179 -------- examples/sensors.py | 76 ++++ pyproject.toml | 78 ++++ pytest.ini | 2 - requirements.txt | 1 - runtime.txt | 1 - setup.py | 28 -- src/buttplug/__init__.py | 70 +++ src/buttplug/_messages/__init__.py | 51 +++ src/buttplug/_messages/base.py | 99 +++++ src/buttplug/_messages/commands.py | 79 ++++ src/buttplug/_messages/device_info.py | 94 ++++ src/buttplug/_messages/handshake.py | 82 ++++ src/buttplug/_utils/__init__.py | 6 + src/buttplug/_utils/events.py | 54 +++ src/buttplug/_utils/message_sorter.py | 84 ++++ src/buttplug/client.py | 438 ++++++++++++++++++ src/buttplug/connector.py | 191 ++++++++ src/buttplug/device.py | 243 ++++++++++ src/buttplug/enums.py | 56 +++ src/buttplug/errors.py | 62 +++ src/buttplug/feature.py | 352 +++++++++++++++ src/buttplug/py.typed | 0 tests/__init__.py | 1 + tests/conftest.py | 81 ++++ tests/test_client.py | 89 ++++ tests/test_client_device.py | 117 ----- tests/test_connector.py | 111 +++++ tests/test_device.py | 284 ++++++++++++ tests/test_messages.py | 400 ++++++++++++++--- uv.lock | 593 +++++++++++++++++++++++++ 62 files changed, 4117 insertions(+), 1622 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 Pipfile delete mode 100644 buttplug/__init__.py delete mode 100644 buttplug/client/__init__.py delete mode 100644 buttplug/client/client.py delete mode 100644 buttplug/client/connector.py delete mode 100644 buttplug/client/websocket_connector.py delete mode 100644 buttplug/core/__init__.py delete mode 100644 buttplug/core/enums.py delete mode 100644 buttplug/core/errors.py delete mode 100644 buttplug/core/messages.py delete mode 100644 buttplug/utils/__init__.py delete mode 100644 buttplug/utils/eventhandler.py delete mode 100644 docs/Makefile delete mode 100644 docs/_static/js/matomo.js delete mode 100644 docs/client.rst delete mode 100644 docs/conf.py delete mode 100644 docs/device.rst delete mode 100644 docs/enums.rst delete mode 100644 docs/errors.rst delete mode 100644 docs/eventhandler.rst delete mode 100644 docs/index.md delete mode 100644 docs/index.rst delete mode 100644 docs/make.bat delete mode 100644 docs/requirements.txt create mode 100644 examples/connection.py create mode 100644 examples/device_control.py create mode 100644 examples/device_enumeration.py create mode 100644 examples/device_info.py create mode 100644 examples/errors.py delete mode 100644 examples/example.py create mode 100644 examples/sensors.py create mode 100644 pyproject.toml delete mode 100644 pytest.ini delete mode 100644 requirements.txt delete mode 100644 runtime.txt delete mode 100644 setup.py create mode 100644 src/buttplug/__init__.py create mode 100644 src/buttplug/_messages/__init__.py create mode 100644 src/buttplug/_messages/base.py create mode 100644 src/buttplug/_messages/commands.py create mode 100644 src/buttplug/_messages/device_info.py create mode 100644 src/buttplug/_messages/handshake.py create mode 100644 src/buttplug/_utils/__init__.py create mode 100644 src/buttplug/_utils/events.py create mode 100644 src/buttplug/_utils/message_sorter.py create mode 100644 src/buttplug/client.py create mode 100644 src/buttplug/connector.py create mode 100644 src/buttplug/device.py create mode 100644 src/buttplug/enums.py create mode 100644 src/buttplug/errors.py create mode 100644 src/buttplug/feature.py create mode 100644 src/buttplug/py.typed create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py delete mode 100644 tests/test_client_device.py create mode 100644 tests/test_connector.py create mode 100644 tests/test_device.py create mode 100644 uv.lock 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/Pipfile b/Pipfile deleted file mode 100644 index b2735a9..0000000 --- a/Pipfile +++ /dev/null @@ -1,12 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -websockets = "==10.1" - -[requires] -python_version = "3.8" diff --git a/README.md b/README.md index 8826d47..a2ce72f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# buttplug-py (DEPRECATED) +# 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) @@ -7,48 +7,128 @@ [![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) -### THIS VERSION OF BUTTPLUG-PY IS DEPRECATED IN FAVOR OF [COMMUNITY MAINTAINED LIBRARIES](https://github.com/buttplugio/awesome-buttplug#python). +Python client library for the [Buttplug](https://buttplug.io) Intimate Hardware Control Protocol (v4). -The community maintained library brings in support for Buttplug Spec v3, as well as many nice to have features. It is recommended to use that instead of this library. +## Installation -## Old Intro +```bash +pip install buttplug +``` -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 Rust CLI](https://github.com/intiface/intiface-cli-rs). +Or with [uv](https://github.com/astral-sh/uv): -For more information on the Buttplug project, check out the project website at -[https://buttplug.io](https://buttplug.io). +```bash +uv add buttplug +``` -## Table Of Contents +## Quick Start -- [Support The Project](#support-the-project) -- [Documentation](#documentation) -- [Examples](#examples) -- [License](#license) +1. **Install and start [Intiface Central](https://intiface.com/central/)** - This is the server that connects to your devices. -## Support The Project +2. **Connect and control devices:** -If you find this project helpful, you can [support us via Patreon](http://patreon.com/qdot) or -[Github Sponsors](http://github.com/sponsors/qdot)! Every donation helps us afford more hardware to -reverse, document, and write code for! +```python +import asyncio +from buttplug import ButtplugClient, OutputType -## Documentation +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.vibrate(0.5) # 50% intensity + await asyncio.sleep(2) + await device.stop() + + await client.disconnect() + +asyncio.run(main()) +``` + +## Features + +- **Simple API**: Easy-to-use methods like `vibrate()`, `rotate()`, `position()` +- **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 + +## Device Control + +```python +# Check device capabilities +if device.has_output(OutputType.VIBRATE): + await device.vibrate(0.75) # 0.0 to 1.0 + +if device.has_output(OutputType.ROTATE): + await device.rotate(0.5, clockwise=True) -Library and API Documentation for buttplug-py is available at +if device.has_output(OutputType.POSITION): + await device.position(1.0, duration_ms=500) # Move over 500ms -https://buttplug-py.docs.buttplug.io +# Read sensors +if device.has_input(InputType.BATTERY): + battery = await device.battery() + print(f"Battery: {battery * 100:.0f}%") -Other recommended reading includes +# Stop device +await device.stop() +``` -- [The Buttplug Protocol Spec](https://buttplug-spec.docs.buttplug.io) -- [The Buttplug Developer Guide](https://buttplug-developer-guide.docs.buttplug.io) +## 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.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: + +- `01_hello_world.py` - Connect and list devices +- `02_device_control.py` - Vibrate, rotate, and position commands +- `03_reading_sensors.py` - Battery and signal strength +- `04_advanced_features.py` - Low-level feature access + +## 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 f11a725..0000000 --- a/buttplug/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.3.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 2d5e7a3..0000000 --- a/buttplug/client/client.py +++ /dev/null @@ -1,361 +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 -import logging, sys - - -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) - logging.info("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 - removed_device: ButtplugClientDevice = self.devices[dr.device_index] - self.devices.pop(dr.device_index) - self.device_removed_handler(removed_device) - 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] = {} - logging.debug(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 05af7bf..0000000 --- a/buttplug/client/websocket_connector.py +++ /dev/null @@ -1,48 +0,0 @@ -from .connector import ButtplugClientConnector, ButtplugClientConnectorError -from ..core.messages import ButtplugMessage -import websockets -import asyncio -import json -from typing import Optional -import logging - - -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: - logging.error("Exiting read loop") - logging.error(e) - break - msg_array = json.loads(message) - for msg in msg_array: - bp_msg = ButtplugMessage.from_dict(msg) - logging.debug(bp_msg) - await self._notify_observers(bp_msg) - - async def send(self, msg: ButtplugMessage): - msg_str = msg.as_json() - msg_str = "[" + msg_str + "]" - logging.debug(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/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..e164757 --- /dev/null +++ b/examples/device_control.py @@ -0,0 +1,98 @@ +"""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 and direction +- 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, 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.vibrate(0.25) + await asyncio.sleep(1) + + print(" Increasing to 50%...") + await device.vibrate(0.5) + await asyncio.sleep(1) + + print(" Full power (100%)...") + await device.vibrate(1.0) + await asyncio.sleep(1) + + # Rotation + if device.has_output(OutputType.ROTATE) or device.has_output( + OutputType.ROTATE_WITH_DIRECTION + ): + print(" Rotating clockwise at 50%...") + await device.rotate(0.5, clockwise=True) + await asyncio.sleep(2) + + print(" Rotating counter-clockwise...") + await device.rotate(0.5, clockwise=False) + await asyncio.sleep(2) + + # Position (strokers/linear devices) + if device.has_output(OutputType.POSITION) or device.has_output( + OutputType.POSITION_WITH_DURATION + ): + print(" Moving to top position...") + await device.position(1.0, duration_ms=500) + await asyncio.sleep(1) + + print(" Moving to bottom position...") + await device.position(0.0, duration_ms=500) + await asyncio.sleep(1) + + print(" Moving to middle...") + await device.position(0.5, duration_ms=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 f7fa03c..0000000 --- a/examples/example.py +++ /dev/null @@ -1,179 +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 -import logging -import sys - -async def cancel_me(): - logging.debug('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. - - logging.info("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): - logging.info("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: - logging.error("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() - logging.info("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. -# -# But first, set up logging -logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) -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 9ecc266..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -websockets==10.1 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 25ed4b4..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>=10', ], - 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..8380bbf --- /dev/null +++ b/src/buttplug/__init__.py @@ -0,0 +1,70 @@ +"""Buttplug - Intimate Hardware Control Library. + +A Python client library for the Buttplug protocol v4. + +Basic usage: + from buttplug import ButtplugClient, 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.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.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", + # 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..c44375a --- /dev/null +++ b/src/buttplug/client.py @@ -0,0 +1,438 @@ +"""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 + + @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/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..4f7111b --- /dev/null +++ b/src/buttplug/device.py @@ -0,0 +1,243 @@ +"""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.enums import InputType, OutputType +from buttplug.errors import ButtplugDeviceError, error_from_code +from buttplug.feature import CommandValue, DeviceFeature + +if TYPE_CHECKING: + from buttplug.client import ButtplugClient + + +class ButtplugDevice: + """Represents a connected Buttplug device. + + Provides both device-level convenience methods (vibrate(), rotate(), etc.) that + control 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.vibrate(0.5) # 50% on all vibrators + + # Feature-level: control specific motor + feature = device.features[0] + await feature.vibrate(0.75) # 75% on motor 0 only + + # Using steps instead of percent + step_count = feature.step_count(OutputType.VIBRATE) + await feature.vibrate(step_count // 2) # Half power via steps + """ + + 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 Convenience Methods ============ + # These control ALL matching features on the device + + async def vibrate(self, value: CommandValue) -> None: + """Set vibration level on all vibrator features. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + + Raises: + ButtplugDeviceError: If device has no vibration features. + """ + await self._send_to_all_features(OutputType.VIBRATE, value) + + async def rotate(self, value: CommandValue, clockwise: bool = True) -> None: + """Set rotation on all rotation features. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + clockwise: Rotation direction. + + Raises: + ButtplugDeviceError: If device has no rotation features. + """ + # Prefer RotateWithDirection, fall back to Rotate + features = self.get_features_with_output(OutputType.ROTATE_WITH_DIRECTION) + if features: + await asyncio.gather(*[f.rotate(value, clockwise) for f in features]) + else: + await self._send_to_all_features(OutputType.ROTATE, value) + + async def oscillate(self, value: CommandValue) -> None: + """Set oscillation on all oscillation features. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + + Raises: + ButtplugDeviceError: If device has no oscillation features. + """ + await self._send_to_all_features(OutputType.OSCILLATE, value) + + async def constrict(self, value: CommandValue) -> None: + """Set constriction on all constriction features. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + + Raises: + ButtplugDeviceError: If device has no constriction features. + """ + await self._send_to_all_features(OutputType.CONSTRICT, value) + + async def position(self, value: CommandValue, duration_ms: int = 0) -> None: + """Move to position on all position features. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + duration_ms: Time in ms to reach position (0 = instant). + + Raises: + ButtplugDeviceError: If device has no position features. + """ + # Prefer HwPositionWithDuration, fall back to Position + features = self.get_features_with_output(OutputType.POSITION_WITH_DURATION) + if features: + await asyncio.gather(*[f.position(value, duration_ms) for f in features]) + else: + await self._send_to_all_features(OutputType.POSITION, value) + + async def stop(self) -> None: + """Stop all device outputs.""" + msg = StopCmd(id=0, device_index=self.index, outputs=True, inputs=False) + response = await self._client._send_device_message(msg) + self._check_response(response) + + async def stop_features(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_level(self) -> bool: + """Check if device has a battery level sensor.""" + return self.has_input(InputType.BATTERY) + + async def battery_level(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_level() + + def has_rssi_level(self) -> bool: + """Check if device has an RSSI sensor.""" + return self.has_input(InputType.RSSI) + + async def rssi_level(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_level() + + # ============ Internal Methods ============ + + async def _send_to_all_features(self, output_type: OutputType, value: CommandValue) -> None: + """Send output command to all features with the specified output type.""" + features = self.get_features_with_output(output_type) + output_name = output_type.value + + if not features: + raise ButtplugDeviceError(f"Device has no {output_name} features") + + # Send to all matching features in parallel + await asyncio.gather(*[f._send_output(output_type, value) for f in features]) + + 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..0e72825 --- /dev/null +++ b/src/buttplug/enums.py @@ -0,0 +1,56 @@ +"""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" + ROTATE_WITH_DIRECTION = "RotateWithDirection" + 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..22f6abc --- /dev/null +++ b/src/buttplug/feature.py @@ -0,0 +1,352 @@ +"""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 + + +# 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.vibrate(0.5) # 50% intensity + + # Using steps (for precise hardware control) + await feature.vibrate(10) # Step 10 out of step_count + + # 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 vibrate(self, value: CommandValue) -> None: + """Set vibration level. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + """ + await self._send_output(OutputType.VIBRATE, value) + + async def rotate(self, value: CommandValue, clockwise: bool = True) -> None: + """Set rotation speed and direction. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + clockwise: Rotation direction. + """ + if self.has_output(OutputType.ROTATE_WITH_DIRECTION): + await self._send_rotate_with_direction(value, clockwise) + else: + await self._send_output(OutputType.ROTATE, value) + + async def oscillate(self, value: CommandValue) -> None: + """Set oscillation speed. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + """ + await self._send_output(OutputType.OSCILLATE, value) + + async def constrict(self, value: CommandValue) -> None: + """Set constriction pressure. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + """ + await self._send_output(OutputType.CONSTRICT, value) + + async def position(self, value: CommandValue, duration_ms: int = 0) -> None: + """Move to position. + + Args: + value: Float 0.0-1.0 (percent) or int (step value). + duration_ms: Time in ms to reach position (0 = instant). + """ + if self.has_output(OutputType.POSITION_WITH_DURATION): + await self._send_position_with_duration(value, duration_ms) + else: + await self._send_output(OutputType.POSITION, 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_level(self) -> float: + """Read battery level (0.0-1.0).""" + reading = await self._read_input(InputType.BATTERY) + return reading / 100.0 + + async def rssi_level(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_rotate_with_direction(self, value: CommandValue, clockwise: bool) -> None: + """Send rotate with direction command.""" + from buttplug._messages import OutputCmd + + step = self.convert_to_step(OutputType.ROTATE_WITH_DIRECTION, value) + + msg = OutputCmd( + id=0, + device_index=self._device_index, + feature_index=self.index, + command={"RotateWithDirection": {"Value": step, "Clockwise": clockwise}}, + ) + 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..1f3996a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,81 @@ +"""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 ce10c13..0000000 --- a/tests/test_client_device.py +++ /dev/null @@ -1,117 +0,0 @@ -import unittest -import pytest -import logging -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): - logging.debug("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..2602fbe --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,284 @@ +"""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={ + "RotateWithDirection": 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_WITH_DIRECTION) 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_WITH_DIRECTION) + 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_level( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """has_battery_level checks for battery sensor.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + assert device.has_battery_level() is True + + def test_has_rssi_level( + self, mock_client: MockClient, multi_feature_device_info: DeviceInfo + ) -> None: + """has_rssi_level checks for RSSI sensor.""" + device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] + assert device.has_rssi_level() 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_WITH_DIRECTION) == 10 diff --git a/tests/test_messages.py b/tests/test_messages.py index 31bc0d6..5ef3124 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -1,74 +1,326 @@ -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..e5930ec --- /dev/null +++ b/uv.lock @@ -0,0 +1,593 @@ +version = 1 +revision = 2 +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.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-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" }, +] From 6436234484a4d59afb17fffaf4cf767728df702b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 25 Jan 2026 22:09:45 -0800 Subject: [PATCH 14/18] feat: Add full application example --- examples/application.py | 176 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 examples/application.py diff --git a/examples/application.py b/examples/application.py new file mode 100644 index 0000000..f423d13 --- /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, 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) or device.has_output( + OutputType.ROTATE_WITH_DIRECTION + ): + 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.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_level() + 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()) From 5e1a99099365543e5b027a7f61243e4c8a6a15bf Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 31 Jan 2026 20:05:54 -0800 Subject: [PATCH 15/18] feat: Update to use run_output(), fix output types Update to look more like Rust and C# --- README.md | 40 ++++++++----- examples/application.py | 12 ++-- examples/device_control.py | 38 ++++++------ src/buttplug/__init__.py | 6 +- src/buttplug/command.py | 23 ++++++++ src/buttplug/device.py | 116 ++++++++----------------------------- src/buttplug/enums.py | 1 - src/buttplug/feature.py | 74 +++++------------------ tests/conftest.py | 47 ++++----------- tests/test_device.py | 28 +++++---- tests/test_messages.py | 86 +++++++++++---------------- 11 files changed, 173 insertions(+), 298 deletions(-) create mode 100644 src/buttplug/command.py diff --git a/README.md b/README.md index a2ce72f..db7e14d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ uv add buttplug ```python import asyncio -from buttplug import ButtplugClient, OutputType +from buttplug import ButtplugClient, DeviceOutputCommand, OutputType async def main(): # Create a client @@ -48,7 +48,7 @@ async def main(): print(f"Found: {device.name}") if device.has_output(OutputType.VIBRATE): - await device.vibrate(0.5) # 50% intensity + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) await asyncio.sleep(2) await device.stop() @@ -59,7 +59,7 @@ asyncio.run(main()) ## Features -- **Simple API**: Easy-to-use methods like `vibrate()`, `rotate()`, `position()` +- **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 @@ -68,15 +68,19 @@ asyncio.run(main()) ## Device Control ```python -# Check device capabilities +from buttplug import DeviceOutputCommand, OutputType + +# Check device capabilities and send commands if device.has_output(OutputType.VIBRATE): - await device.vibrate(0.75) # 0.0 to 1.0 + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.75)) if device.has_output(OutputType.ROTATE): - await device.rotate(0.5, clockwise=True) + await device.run_output(DeviceOutputCommand(OutputType.ROTATE, 0.5)) -if device.has_output(OutputType.POSITION): - await device.position(1.0, duration_ms=500) # Move over 500ms +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): @@ -99,7 +103,7 @@ 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.vibrate(0.25) + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.25)) client.on_device_added = on_device_added ``` @@ -108,10 +112,20 @@ client.on_device_added = on_device_added See the [examples/](examples/) directory for more detailed examples: -- `01_hello_world.py` - Connect and list devices -- `02_device_control.py` - Vibrate, rotate, and position commands -- `03_reading_sensors.py` - Battery and signal strength -- `04_advanced_features.py` - Low-level feature access +- `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 diff --git a/examples/application.py b/examples/application.py index f423d13..4bf8f23 100644 --- a/examples/application.py +++ b/examples/application.py @@ -11,7 +11,7 @@ import asyncio -from buttplug import ButtplugClient, InputType, OutputType +from buttplug import ButtplugClient, DeviceOutputCommand, InputType, OutputType from buttplug.errors import ButtplugDeviceError, ButtplugError @@ -23,9 +23,7 @@ def print_device_capabilities(device) -> None: outputs = [] if device.has_output(OutputType.VIBRATE): outputs.append("Vibrate") - if device.has_output(OutputType.ROTATE) or device.has_output( - OutputType.ROTATE_WITH_DIRECTION - ): + if device.has_output(OutputType.ROTATE): outputs.append("Rotate") if device.has_output(OutputType.OSCILLATE): outputs.append("Oscillate") @@ -130,7 +128,9 @@ async def main() -> None: intensity = percent / 100.0 for device in devices: if device.has_output(OutputType.VIBRATE): - await device.vibrate(intensity) + await device.run_output( + DeviceOutputCommand(OutputType.VIBRATE, intensity) + ) print(f" {device.name}: vibrating at {percent}%") else: print(" Usage: v <0-100>") @@ -147,7 +147,7 @@ async def main() -> None: for device in devices: if device.has_input(InputType.BATTERY): try: - battery = await device.battery_level() + 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}") diff --git a/examples/device_control.py b/examples/device_control.py index e164757..a018f75 100644 --- a/examples/device_control.py +++ b/examples/device_control.py @@ -2,7 +2,7 @@ This example shows how to control different types of devices: - Vibrators: Set vibration intensity -- Rotators: Set rotation speed and direction +- Rotators: Set rotation speed - Strokers: Move to position over time The example checks what each device supports before sending commands, @@ -17,7 +17,7 @@ import asyncio -from buttplug import ButtplugClient, OutputType +from buttplug import ButtplugClient, DeviceOutputCommand, OutputType async def main() -> None: @@ -47,43 +47,41 @@ async def main() -> None: # Vibration if device.has_output(OutputType.VIBRATE): print(" Starting vibration at 25%...") - await device.vibrate(0.25) + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.25)) await asyncio.sleep(1) print(" Increasing to 50%...") - await device.vibrate(0.5) + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) await asyncio.sleep(1) print(" Full power (100%)...") - await device.vibrate(1.0) + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 1.0)) await asyncio.sleep(1) # Rotation - if device.has_output(OutputType.ROTATE) or device.has_output( - OutputType.ROTATE_WITH_DIRECTION - ): - print(" Rotating clockwise at 50%...") - await device.rotate(0.5, clockwise=True) - await asyncio.sleep(2) - - print(" Rotating counter-clockwise...") - await device.rotate(0.5, clockwise=False) + 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) or device.has_output( - OutputType.POSITION_WITH_DURATION - ): + if device.has_output(OutputType.POSITION_WITH_DURATION): print(" Moving to top position...") - await device.position(1.0, duration_ms=500) + await device.run_output( + DeviceOutputCommand(OutputType.POSITION_WITH_DURATION, 1.0, duration=500) + ) await asyncio.sleep(1) print(" Moving to bottom position...") - await device.position(0.0, duration_ms=500) + await device.run_output( + DeviceOutputCommand(OutputType.POSITION_WITH_DURATION, 0.0, duration=500) + ) await asyncio.sleep(1) print(" Moving to middle...") - await device.position(0.5, duration_ms=250) + await device.run_output( + DeviceOutputCommand(OutputType.POSITION_WITH_DURATION, 0.5, duration=250) + ) await asyncio.sleep(1) # Stop the device diff --git a/src/buttplug/__init__.py b/src/buttplug/__init__.py index 8380bbf..b47141b 100644 --- a/src/buttplug/__init__.py +++ b/src/buttplug/__init__.py @@ -3,7 +3,7 @@ A Python client library for the Buttplug protocol v4. Basic usage: - from buttplug import ButtplugClient, OutputType + from buttplug import ButtplugClient, DeviceOutputCommand, OutputType async def main(): client = ButtplugClient("My App") @@ -15,7 +15,7 @@ async def main(): for device in client.devices.values(): if device.has_output(OutputType.VIBRATE): - await device.vibrate(0.5) + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) await asyncio.sleep(1) await device.stop() @@ -28,6 +28,7 @@ async def main(): # 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 @@ -64,6 +65,7 @@ async def main(): # Client and Device "ButtplugClient", "ButtplugDevice", + "DeviceOutputCommand", # Feature-level control "DeviceFeature", "CommandValue", 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/device.py b/src/buttplug/device.py index 4f7111b..7ae0a31 100644 --- a/src/buttplug/device.py +++ b/src/buttplug/device.py @@ -12,9 +12,10 @@ 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 CommandValue, DeviceFeature +from buttplug.feature import DeviceFeature if TYPE_CHECKING: from buttplug.client import ButtplugClient @@ -23,8 +24,8 @@ class ButtplugDevice: """Represents a connected Buttplug device. - Provides both device-level convenience methods (vibrate(), rotate(), etc.) that - control all matching features, and per-feature access for fine-grained control. + 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%) @@ -32,15 +33,15 @@ class ButtplugDevice: Example: # Device-level: control all vibrators at once - await device.vibrate(0.5) # 50% on all vibrators + await device.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) # Feature-level: control specific motor feature = device.features[0] - await feature.vibrate(0.75) # 75% on motor 0 only + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.75)) # Using steps instead of percent step_count = feature.step_count(OutputType.VIBRATE) - await feature.vibrate(step_count // 2) # Half power via steps + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, step_count // 2)) """ def __init__(self, client: ButtplugClient, device_info: DeviceInfo) -> None: @@ -97,83 +98,23 @@ def get_features_with_input(self, input_type: InputType | str) -> list[DeviceFea """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 Convenience Methods ============ - # These control ALL matching features on the device + # ============ Device-Level Command Methods ============ - async def vibrate(self, value: CommandValue) -> None: - """Set vibration level on all vibrator features. + async def run_output(self, command: DeviceOutputCommand) -> None: + """Send an output command to all features matching the output type. Args: - value: Float 0.0-1.0 (percent) or int (step value). + command: The output command specifying type, value, and optional duration. Raises: - ButtplugDeviceError: If device has no vibration features. + ButtplugDeviceError: If device has no features matching the output type. """ - await self._send_to_all_features(OutputType.VIBRATE, value) - - async def rotate(self, value: CommandValue, clockwise: bool = True) -> None: - """Set rotation on all rotation features. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - clockwise: Rotation direction. - - Raises: - ButtplugDeviceError: If device has no rotation features. - """ - # Prefer RotateWithDirection, fall back to Rotate - features = self.get_features_with_output(OutputType.ROTATE_WITH_DIRECTION) - if features: - await asyncio.gather(*[f.rotate(value, clockwise) for f in features]) - else: - await self._send_to_all_features(OutputType.ROTATE, value) - - async def oscillate(self, value: CommandValue) -> None: - """Set oscillation on all oscillation features. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - - Raises: - ButtplugDeviceError: If device has no oscillation features. - """ - await self._send_to_all_features(OutputType.OSCILLATE, value) - - async def constrict(self, value: CommandValue) -> None: - """Set constriction on all constriction features. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - - Raises: - ButtplugDeviceError: If device has no constriction features. - """ - await self._send_to_all_features(OutputType.CONSTRICT, value) - - async def position(self, value: CommandValue, duration_ms: int = 0) -> None: - """Move to position on all position features. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - duration_ms: Time in ms to reach position (0 = instant). - - Raises: - ButtplugDeviceError: If device has no position features. - """ - # Prefer HwPositionWithDuration, fall back to Position - features = self.get_features_with_output(OutputType.POSITION_WITH_DURATION) - if features: - await asyncio.gather(*[f.position(value, duration_ms) for f in features]) - else: - await self._send_to_all_features(OutputType.POSITION, value) - - async def stop(self) -> None: - """Stop all device outputs.""" - msg = StopCmd(id=0, device_index=self.index, outputs=True, inputs=False) - response = await self._client._send_device_message(msg) - self._check_response(response) + 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_features(self, inputs: bool = True, outputs: bool = True) -> None: + async def stop(self, inputs: bool = True, outputs: bool = True) -> None: """Stop device outputs and/or unsubscribe from inputs. Args: @@ -186,11 +127,11 @@ async def stop_features(self, inputs: bool = True, outputs: bool = True) -> None # ============ Sensor Convenience Methods ============ - def has_battery_level(self) -> bool: + def has_battery(self) -> bool: """Check if device has a battery level sensor.""" return self.has_input(InputType.BATTERY) - async def battery_level(self) -> float: + async def battery(self) -> float: """Read battery level from first battery sensor. Returns: @@ -202,13 +143,13 @@ async def battery_level(self) -> float: features = self.get_features_with_input(InputType.BATTERY) if not features: raise ButtplugDeviceError("Device has no battery sensor") - return await features[0].battery_level() + return await features[0].battery() - def has_rssi_level(self) -> bool: + def has_rssi(self) -> bool: """Check if device has an RSSI sensor.""" return self.has_input(InputType.RSSI) - async def rssi_level(self) -> int: + async def rssi(self) -> int: """Read RSSI signal strength from first RSSI sensor. Returns: @@ -220,21 +161,10 @@ async def rssi_level(self) -> int: features = self.get_features_with_input(InputType.RSSI) if not features: raise ButtplugDeviceError("Device has no RSSI sensor") - return await features[0].rssi_level() + return await features[0].rssi() # ============ Internal Methods ============ - async def _send_to_all_features(self, output_type: OutputType, value: CommandValue) -> None: - """Send output command to all features with the specified output type.""" - features = self.get_features_with_output(output_type) - output_name = output_type.value - - if not features: - raise ButtplugDeviceError(f"Device has no {output_name} features") - - # Send to all matching features in parallel - await asyncio.gather(*[f._send_output(output_type, value) for f in features]) - def _check_response(self, response: ButtplugMessage) -> None: """Check response and raise if error.""" if isinstance(response, Error): diff --git a/src/buttplug/enums.py b/src/buttplug/enums.py index 0e72825..97c1731 100644 --- a/src/buttplug/enums.py +++ b/src/buttplug/enums.py @@ -19,7 +19,6 @@ class OutputType(StrEnum): VIBRATE = "Vibrate" ROTATE = "Rotate" - ROTATE_WITH_DIRECTION = "RotateWithDirection" OSCILLATE = "Oscillate" CONSTRICT = "Constrict" SPRAY = "Spray" diff --git a/src/buttplug/feature.py b/src/buttplug/feature.py index 22f6abc..eb48eec 100644 --- a/src/buttplug/feature.py +++ b/src/buttplug/feature.py @@ -15,6 +15,7 @@ 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) @@ -34,10 +35,10 @@ class DeviceFeature: Example: # Using percent (recommended for most cases) - await feature.vibrate(0.5) # 50% intensity + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, 0.5)) # Using steps (for precise hardware control) - await feature.vibrate(10) # Step 10 out of step_count + await feature.run_output(DeviceOutputCommand(OutputType.VIBRATE, 10)) # Check step range first print(f"Steps: {feature.step_count(OutputType.VIBRATE)}") @@ -186,53 +187,19 @@ def convert_to_step(self, output_type: OutputType | str, value: CommandValue) -> # ============ Feature-Level Command Methods ============ - async def vibrate(self, value: CommandValue) -> None: - """Set vibration level. + async def run_output(self, command: DeviceOutputCommand) -> None: + """Send an output command to this feature. Args: - value: Float 0.0-1.0 (percent) or int (step value). - """ - await self._send_output(OutputType.VIBRATE, value) - - async def rotate(self, value: CommandValue, clockwise: bool = True) -> None: - """Set rotation speed and direction. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - clockwise: Rotation direction. - """ - if self.has_output(OutputType.ROTATE_WITH_DIRECTION): - await self._send_rotate_with_direction(value, clockwise) - else: - await self._send_output(OutputType.ROTATE, value) - - async def oscillate(self, value: CommandValue) -> None: - """Set oscillation speed. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - """ - await self._send_output(OutputType.OSCILLATE, value) - - async def constrict(self, value: CommandValue) -> None: - """Set constriction pressure. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - """ - await self._send_output(OutputType.CONSTRICT, value) + command: The output command specifying type, value, and optional duration. - async def position(self, value: CommandValue, duration_ms: int = 0) -> None: - """Move to position. - - Args: - value: Float 0.0-1.0 (percent) or int (step value). - duration_ms: Time in ms to reach position (0 = instant). + Raises: + ButtplugDeviceError: If this feature doesn't support the output type. """ - if self.has_output(OutputType.POSITION_WITH_DURATION): - await self._send_position_with_duration(value, duration_ms) + 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(OutputType.POSITION, value) + await self._send_output(command.output_type, command.value) async def stop(self) -> None: """Stop this feature's outputs.""" @@ -248,12 +215,12 @@ async def stop(self) -> None: response = await self._client._send_device_message(msg) self._check_response(response) - async def battery_level(self) -> float: + 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_level(self) -> int: + async def rssi(self) -> int: """Read RSSI signal strength (dBm).""" return await self._read_input(InputType.RSSI) @@ -275,21 +242,6 @@ async def _send_output(self, output_type: OutputType, value: CommandValue) -> No response = await self._client._send_device_message(msg) self._check_response(response) - async def _send_rotate_with_direction(self, value: CommandValue, clockwise: bool) -> None: - """Send rotate with direction command.""" - from buttplug._messages import OutputCmd - - step = self.convert_to_step(OutputType.ROTATE_WITH_DIRECTION, value) - - msg = OutputCmd( - id=0, - device_index=self._device_index, - feature_index=self.index, - command={"RotateWithDirection": {"Value": step, "Clockwise": clockwise}}, - ) - 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 diff --git a/tests/conftest.py b/tests/conftest.py index 1f3996a..20e8ce9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,32 +18,19 @@ def sample_device_list_data() -> dict: "0": { "FeatureIndex": 0, "FeatureDescription": "Clitoral Stimulator", - "Output": { - "Vibrate": { - "Value": [0, 20] - } - } + "Output": {"Vibrate": {"Value": [0, 20]}}, }, "1": { "FeatureIndex": 1, "FeatureDescription": "G-Spot Motor", - "Output": { - "Vibrate": { - "Value": [0, 20] - } - } + "Output": {"Vibrate": {"Value": [0, 20]}}, }, "2": { "FeatureIndex": 2, "FeatureDescription": "Battery", - "Input": { - "Battery": { - "Value": [[0, 100]], - "Command": ["Read"] - } - } - } - } + "Input": {"Battery": {"Value": [[0, 100]], "Command": ["Read"]}}, + }, + }, }, "1": { "DeviceName": "Test Stroker", @@ -54,28 +41,16 @@ def sample_device_list_data() -> dict: "FeatureIndex": 0, "FeatureDescription": "Stroker", "Output": { - "HwPositionWithDuration": { - "Value": [0, 100], - "Duration": [0, 1000] - } - } + "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 - } - } - } + return {"Id": 5, "DeviceIndex": 0, "FeatureIndex": 2, "Reading": {"Battery": {"Value": 75}}} diff --git a/tests/test_device.py b/tests/test_device.py index 2602fbe..dbad5f7 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -57,7 +57,9 @@ def position_feature(self, mock_client: MockClient) -> DeviceFeature: feature_index=0, feature_description="Stroker", output={ - "HwPositionWithDuration": FeatureOutputDefinition(value=(0, 100), duration=(0, 1000)) + "HwPositionWithDuration": FeatureOutputDefinition( + value=(0, 100), duration=(0, 1000) + ) }, input=None, ) @@ -185,9 +187,7 @@ def multi_feature_device_info(self) -> DeviceInfo: 2: DeviceFeatureDefinition( feature_index=2, feature_description="Rotator", - output={ - "RotateWithDirection": FeatureOutputDefinition(value=(0, 10), duration=None) - }, + output={"Rotate": FeatureOutputDefinition(value=(0, 10), duration=None)}, input=None, ), 3: DeviceFeatureDefinition( @@ -218,7 +218,7 @@ def test_has_output( 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_WITH_DIRECTION) is True + assert device.has_output(OutputType.ROTATE) is True assert device.has_output(OutputType.POSITION) is False def test_has_input( @@ -239,7 +239,7 @@ def test_get_features_with_output( vibrate_features = device.get_features_with_output(OutputType.VIBRATE) assert len(vibrate_features) == 2 - rotate_features = device.get_features_with_output(OutputType.ROTATE_WITH_DIRECTION) + rotate_features = device.get_features_with_output(OutputType.ROTATE) assert len(rotate_features) == 1 position_features = device.get_features_with_output(OutputType.POSITION) @@ -257,19 +257,17 @@ def test_get_features_with_input( rssi_features = device.get_features_with_input(InputType.RSSI) assert len(rssi_features) == 0 - def test_has_battery_level( + def test_has_battery( self, mock_client: MockClient, multi_feature_device_info: DeviceInfo ) -> None: - """has_battery_level checks for battery sensor.""" + """has_battery checks for battery sensor.""" device = ButtplugDevice(mock_client, multi_feature_device_info) # type: ignore[arg-type] - assert device.has_battery_level() is True + assert device.has_battery() is True - def test_has_rssi_level( - self, mock_client: MockClient, multi_feature_device_info: DeviceInfo - ) -> None: - """has_rssi_level checks for RSSI sensor.""" + 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_level() is False + assert device.has_rssi() is False def test_feature_step_count( self, mock_client: MockClient, multi_feature_device_info: DeviceInfo @@ -281,4 +279,4 @@ def test_feature_step_count( assert vibrate_feature.step_count(OutputType.VIBRATE) == 20 rotate_feature = device.features[2] - assert rotate_feature.step_count(OutputType.ROTATE_WITH_DIRECTION) == 10 + assert rotate_feature.step_count(OutputType.ROTATE) == 10 diff --git a/tests/test_messages.py b/tests/test_messages.py index 5ef3124..6cc5139 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -32,14 +32,16 @@ def test_request_server_info_serialize(self): msg = RequestServerInfo(id=1, client_name="Test Client") result = msg.to_protocol() - assert result == [{ - "RequestServerInfo": { - "Id": 1, - "ClientName": "Test Client", - "ProtocolVersionMajor": 4, - "ProtocolVersionMinor": 0, + assert result == [ + { + "RequestServerInfo": { + "Id": 1, + "ClientName": "Test Client", + "ProtocolVersionMajor": 4, + "ProtocolVersionMinor": 0, + } } - }] + ] def test_request_server_info_deserialize(self): """RequestServerInfo deserializes from PascalCase.""" @@ -199,22 +201,19 @@ class TestCommandMessages: 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}} - ) + 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}} + 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.""" @@ -222,7 +221,7 @@ def test_output_cmd_position_with_duration(self): id=6, device_index=1, feature_index=0, - command={"HwPositionWithDuration": {"Value": 80, "Duration": 250}} + command={"HwPositionWithDuration": {"Value": 80, "Duration": 250}}, ) result = msg.to_protocol() @@ -232,24 +231,20 @@ def test_output_cmd_position_with_duration(self): def test_input_cmd_serialize(self): """InputCmd serializes correctly.""" - msg = InputCmd( - id=7, - device_index=0, - feature_index=2, - input_type="Battery", - command="Read" - ) + 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" + 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.""" @@ -267,27 +262,16 @@ def test_stop_cmd_all_devices(self): msg = StopCmd(id=8) result = msg.to_protocol() - assert result == [{ - "StopCmd": { - "Id": 8, - "Inputs": True, - "Outputs": True - } - }] + 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 - } - }] + assert result == [ + {"StopCmd": {"Id": 9, "DeviceIndex": 0, "Inputs": False, "Outputs": True}} + ] class TestMessageParsing: From 4290dd2b76d90de977c1c0eb77b2860b83094c67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:24:04 +0000 Subject: [PATCH 16/18] build(deps): bump pygments from 2.19.2 to 2.20.0 Bumps [pygments](https://github.com/pygments/pygments) from 2.19.2 to 2.20.0. - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.19.2...2.20.0) --- updated-dependencies: - dependency-name: pygments dependency-version: 2.20.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index e5930ec..aea448a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -384,11 +384,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +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/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { 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]] From e21318ff37b2618457ccf26d69cbe806778496ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:09:21 +0000 Subject: [PATCH 17/18] build(deps-dev): bump pytest from 9.0.2 to 9.0.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index aea448a..f1b7429 100644 --- a/uv.lock +++ b/uv.lock @@ -393,7 +393,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -404,9 +404,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +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/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { 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]] From 82abb2cf637a0d92455309208f5aec5741ac66c0 Mon Sep 17 00:00:00 2001 From: Jonathan Lamothe Date: Sun, 6 Sep 2026 00:55:47 -0400 Subject: [PATCH 18/18] allow `async with` to be used with a `ButtplugClient` object ...to ensure it's cleanly disconnected when no longer required --- src/buttplug/client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/buttplug/client.py b/src/buttplug/client.py index c44375a..05b8211 100644 --- a/src/buttplug/client.py +++ b/src/buttplug/client.py @@ -81,6 +81,13 @@ def __init__(self, name: str) -> None: 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."""