From 3be7d241509afa417e4e2a47d126c18c0e6c61cd Mon Sep 17 00:00:00 2001 From: tmu Date: Sat, 5 Aug 2023 19:30:07 +0200 Subject: [PATCH 01/69] add workflow to publish to pypi --- .github/workflows/publish-pypi.yml | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/publish-pypi.yml diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..8aba9b8 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,31 @@ +name: Publish to PyPI +on: push +jobs: + build-n-publish: + name: Build and publish to PyPI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python3 -m + build + --sdist + --wheel + --outdir dist/ + . + - name: Publish distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_KEY }} \ No newline at end of file From 63d709bd7b79d64b561b42d58686054f49d35312 Mon Sep 17 00:00:00 2001 From: tmu Date: Sat, 5 Aug 2023 20:03:32 +0200 Subject: [PATCH 02/69] remove lazerbass example to break dependency on pygame --- .github/workflows/python-test.yml | 17 +---- examples/reaktor_lazerbass.py | 117 ------------------------------ 2 files changed, 2 insertions(+), 132 deletions(-) delete mode 100644 examples/reaktor_lazerbass.py diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index d387a55..03963eb 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -28,7 +28,6 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install flake8 pytest mypy - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -36,18 +35,6 @@ jobs: # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Check with mypy - run: | - if [[ "${{ matrix.python-version }}" = "3.11" ]] - then - # --pre needed for Python 3.11 which doesn't have wheels (and may not - # be fully supported) in version 2.1.2. Once 2.1.3 is released this - # can be removed. Due to https://github.com/pygame/pygame/issues/3572 - # we can't apply this for all Python versions. - pip install --pre pygame # Needed for examples - else - pip install pygame # Needed for examples - fi - mypy pythonosc examples + run: mypy pythonosc examples - name: Test with pytest - run: | - pytest + run: pytest diff --git a/examples/reaktor_lazerbass.py b/examples/reaktor_lazerbass.py deleted file mode 100644 index 8ecd8ff..0000000 --- a/examples/reaktor_lazerbass.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Example to drive/show reaktor's lazerbass instrument in pygame.""" -import argparse -import pygame -import multiprocessing -import queue -import logging - -from typing import Tuple - -from pygame.constants import QUIT - -from pythonosc.dispatcher import Dispatcher -from pythonosc import osc_server - -logging.basicConfig( - level=logging.DEBUG, - format='[%(levelname)s] (%(threadName)-10s) %(message)s', -) - -_BLACK = pygame.Color(0, 0, 0) -_WHITE = pygame.Color(255, 255, 255) - - -class ReaktorDisplay(multiprocessing.Process): - def __init__(self, bq): - multiprocessing.Process.__init__(self) - self._bq = bq - - def run(self): - pygame.init() - font = pygame.font.SysFont("monospace", 15) - screen = pygame.display.set_mode((640, 480)) # FULLSCREEN - running = True - dirty = True - # OSC controlled parameters. - self._parameters = { - 'beating': 0.0, - 'blocks': 0.0, - 'basic_Model': 0.0, - 'Do!': 0.0, - } - while running: - for event in pygame.event.get(): - if event.type == QUIT: - running = False - if dirty: - screen.fill(_BLACK) - # Draw a gauge using rectangles. - # Left, top, width, height. - pygame.draw.rect( - screen, _WHITE, [10, 10, 50, 100], 2) - pygame.draw.rect( - screen, _WHITE, [10, 110, 50, -int(self._parameters['beating'] * 100)]) - - # Draw a button-like square for on/off display. - pygame.draw.rect( - screen, _WHITE, [10, 200, 50, 50], 2) - pygame.draw.rect( - screen, _WHITE, [10, 200, 50, 50 if self._parameters['blocks'] >= 0.5 else 0]) - - # Show actual values. - for index, [key, val] in enumerate(self._parameters.items()): - label = font.render("{0}: {1}".format(key, val), 1, _WHITE) - screen.blit(label, (200, index * 15)) - pygame.display.flip() - dirty = False - try: - what, value = self._bq.get(True) - self._parameters[what] = value - dirty = True - logging.debug('Received new value {0} = {1}'.format(what, value)) - except queue.Empty: - running = False - pygame.quit() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument( - "--server_ip", default="0.0.0.0", - help="The ip to listen to for reaktor OSC messages") - parser.add_argument( - "--server_port", type=int, default=8000, - help="The port to listen on for reaktor OSC messages") - # parser.add_argument("--client_ip", - # default="127.0.0.1", help="The ip to listen on") - # parser.add_argument("--client_port", - # type=int, default=5005, help="The port to listen on") - args = parser.parse_args() - - # client = udp_client.UDPClient(args.client_ip, args.client_port) - - bq = multiprocessing.Queue() # type: multiprocessing.Queue[Tuple[str, float]] - reaktor = ReaktorDisplay(bq) - - - def put_in_queue(args, value): - """Put a named argument in the queue to be able to use a single queue.""" - bq.put((args[0], value)) - - - dispatcher = Dispatcher() - dispatcher.map("/debug", logging.debug) - dispatcher.map("/beating", put_in_queue, "beating") - dispatcher.map("/blocks", put_in_queue, "blocks") - dispatcher.map("/basic_Model", put_in_queue, "basic_Model") - dispatcher.map("/Do!", put_in_queue, "Do!") - - server = osc_server.ThreadingOSCUDPServer( - (args.server_ip, args.server_port), dispatcher) - logging.info("Serving on {}".format(server.server_address)) - - # Exit thread when the main thread terminates. - reaktor.daemon = True - reaktor.start() - - server.serve_forever() From 02eb707da7dee8c88f34ca8d09eb79eee936b270 Mon Sep 17 00:00:00 2001 From: tmu Date: Sat, 5 Aug 2023 20:04:08 +0200 Subject: [PATCH 03/69] upgrade to mypi build with pyproject.toml config --- CHANGELOG.md | 5 +++++ pyproject.toml | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 pyproject.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 97738b3..3d9b7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Nothing yet +## [1.8.2] + +- Changed packaging method to pypa/build +- Removed pygame example to simplify dependencies + ## [1.8.1] - Add options to UdpClient to force the use of IPv4 when IPv6 is available and vice versa diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c4dd573 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "python-osc" +version="1.8.2" +description="Open Sound Control server and client implementations in pure Python" +readme="README.rst" +requires-python=">=3.7" +license = {file = "LICENSE.txt"} +authors = [ + {name = "attwad", email = "tmusoft@gmail.com"}, +] +keywords = ["osc", "sound", "midi", "music"] +classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: Freely Distributable', + 'Programming Language :: Python :: 3', + 'Topic :: Multimedia :: Sound/Audio', + 'Topic :: System :: Networking', +] +[project.urls] +Repository = "https://github.com/attwad/python-osc" \ No newline at end of file From 504d4eec34052db6e65987121ef6146e92d1f2a9 Mon Sep 17 00:00:00 2001 From: tmu Date: Sat, 5 Aug 2023 20:04:51 +0200 Subject: [PATCH 04/69] remove setup.py not needed anymore --- setup.py | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100755 setup.py diff --git a/setup.py b/setup.py deleted file mode 100755 index 95f3193..0000000 --- a/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python - -try: - from setuptools import setup - - test_extras = { - 'test_suite': 'pythonosc.test', - } -except ImportError: - from distutils.core import setup - - test_extras = {} - -setup( - name='python-osc', - version='1.8.1', - author='attwad', - author_email='tmusoft@gmail.com', - description=( - 'Open Sound Control server and client implementations in pure Python'), - long_description=open('README.rst').read(), - long_description_content_type='text/x-rst', - url='https://github.com/attwad/python-osc', - platforms='any', - packages=[ - 'pythonosc', - 'pythonosc.parsing', - 'pythonosc.test', - 'pythonosc.test.parsing', - ], - keywords='osc sound midi music', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: Freely Distributable', - 'Programming Language :: Python :: 3', - 'Topic :: Multimedia :: Sound/Audio', - 'Topic :: System :: Networking', - ], - **test_extras -) From 52d4296e745207b9e615d6135bfb7ac5b2507c30 Mon Sep 17 00:00:00 2001 From: tmu Date: Sat, 5 Aug 2023 20:13:07 +0200 Subject: [PATCH 05/69] using trusted publisher setup to publish to pypi --- .github/workflows/publish-pypi.yml | 7 ++++--- CHANGELOG.md | 2 +- README.rst | 7 ------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 8aba9b8..4c6a52a 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -4,6 +4,9 @@ jobs: build-n-publish: name: Build and publish to PyPI runs-on: ubuntu-latest + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write steps: - uses: actions/checkout@v3 - name: Set up Python @@ -26,6 +29,4 @@ jobs: . - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_KEY }} \ No newline at end of file + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9b7ee..552cd56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] -- Nothing yet +- Using trusted publisher setup to publish to pypi ## [1.8.2] diff --git a/README.rst b/README.rst index 257a812..d5dbf6e 100644 --- a/README.rst +++ b/README.rst @@ -43,13 +43,6 @@ to install it just use pip (prefered): $ pip install python-osc -or from the raw sources for the development version: - -.. code-block:: bash - - $ python setup.py test - $ python setup.py install - Examples ======== From 913fd5c1dad1b3adc8aa7551f572833518f95b2e Mon Sep 17 00:00:00 2001 From: tmu Date: Sat, 5 Aug 2023 20:17:18 +0200 Subject: [PATCH 06/69] update version --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552cd56..3b34d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +## [1.8.3] + - Using trusted publisher setup to publish to pypi ## [1.8.2] diff --git a/pyproject.toml b/pyproject.toml index c4dd573..5a3cfbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-osc" -version="1.8.2" +version="1.8.3" description="Open Sound Control server and client implementations in pure Python" readme="README.rst" requires-python=">=3.7" From ec5126488f743b4217d7a5f42c2b5aad131e1dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Thu, 25 Jul 2024 23:49:02 +0000 Subject: [PATCH 07/69] Fix invalid escape sequence flagged by pytest ``` pythonosc/dispatcher.py:153 /workspaces/python-osc/pythonosc/dispatcher.py:153: DeprecationWarning: invalid escape sequence '\+' pattern = pattern.replace('\\*', '[\\w|\+]*') ``` --- pythonosc/dispatcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index e68e9cf..ea74480 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -150,7 +150,7 @@ def handlers_for_address(self, address_pattern: str) -> Generator[Handler, None, pattern = escaped_address_pattern.replace('\\?', '\\w?') # '*' in the OSC Address Pattern matches any sequence of zero or more # characters. - pattern = pattern.replace('\\*', '[\w|\+]*') + pattern = pattern.replace('\\*', '[\\w|\\+]*') # The rest of the syntax in the specification is like the re module so # we're fine. pattern = pattern + '$' From 9a887893b9e3d880fdae6c521f7c2dcbbead8270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 00:02:40 +0000 Subject: [PATCH 08/69] fix a buch of flake8 errors --- docs/conf.py | 2 +- examples/dispatcher.py | 4 ++-- examples/simple_2way.py | 15 ++++++--------- examples/simple_client.py | 1 - pythonosc/osc_message_builder.py | 4 +++- pythonosc/osc_server.py | 2 +- pythonosc/parsing/ntp.py | 21 ++++++++------------- pythonosc/parsing/osc_types.py | 2 +- pythonosc/test/parsing/test_osc_types.py | 2 +- pythonosc/test/test_dispatcher.py | 4 ++-- pythonosc/test/test_osc_bundle.py | 2 +- pythonosc/test/test_udp_client.py | 1 - pythonosc/udp_client.py | 1 + 13 files changed, 27 insertions(+), 34 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 44c56b0..da3c34a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -180,4 +180,4 @@ epub_exclude_files = ['search.html'] -# -- Extension configuration ------------------------------------------------- \ No newline at end of file +# -- Extension configuration ------------------------------------------------- diff --git a/examples/dispatcher.py b/examples/dispatcher.py index b738c71..59bb2de 100644 --- a/examples/dispatcher.py +++ b/examples/dispatcher.py @@ -1,5 +1,7 @@ from pythonosc.dispatcher import Dispatcher from typing import List, Any +from pythonosc.osc_server import BlockingOSCUDPServer +from pythonosc.udp_client import SimpleUDPClient dispatcher = Dispatcher() @@ -22,8 +24,6 @@ def set_filter(address: str, *args: List[Any]) -> None: dispatcher.map("/filter*", set_filter) # Map wildcard address to set_filter function # Set up server and client for testing -from pythonosc.osc_server import BlockingOSCUDPServer -from pythonosc.udp_client import SimpleUDPClient server = BlockingOSCUDPServer(("127.0.0.1", 1337), dispatcher) client = SimpleUDPClient("127.0.0.1", 1337) diff --git a/examples/simple_2way.py b/examples/simple_2way.py index 1034c87..7ad62db 100644 --- a/examples/simple_2way.py +++ b/examples/simple_2way.py @@ -1,5 +1,5 @@ """Small example OSC server anbd client combined -This program listens to serveral addresses and print if there is an input. +This program listens to serveral addresses and print if there is an input. It also transmits on a different port at the same time random values to different addresses. This can be used to demonstrate concurrent send and recieve over OSC """ @@ -7,7 +7,6 @@ import argparse import random import time -import math import threading from pythonosc import udp_client @@ -22,6 +21,7 @@ def print_fader_handler(unused_addr, args, value): def print_xy_fader_handler(unused_addr, args, value1, value2): print("[{0}] ~ {1:0.2f} ~ {2:0.2f}".format(args[0], value2, value1)) + if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--serverip", default="127.0.0.1", help="The ip to listen on") @@ -30,8 +30,7 @@ def print_xy_fader_handler(unused_addr, args, value1, value2): parser.add_argument("--clientport", type=int, default=5006, help="The port the OSC Client is listening on") args = parser.parse_args() - - # listen to addresses and print changes in values + # listen to addresses and print changes in values dispatcher = Dispatcher() dispatcher.map("/1/push2", print) dispatcher.map("/1/fader1", print_fader_handler, "Focus") @@ -39,8 +38,8 @@ def print_xy_fader_handler(unused_addr, args, value1, value2): dispatcher.map("/1/xy1", print_xy_fader_handler, "Pan-Tilt") dispatcher.map("/ping", print) -def start_server(ip, port): +def start_server(ip, port): print("Starting Server") server = osc_server.ThreadingOSCUDPServer( (ip, port), dispatcher) @@ -48,6 +47,7 @@ def start_server(ip, port): thread = threading.Thread(target=server.serve_forever) thread.start() + def start_client(ip, port): print("Starting Client") client = udp_client.SimpleUDPClient(ip, port) @@ -57,7 +57,7 @@ def start_client(ip, port): # send random values between 0-1 to the three addresses -def random_values(client): +def random_values(client): while True: for x in range(10): client.send_message("/1/fader2", random.random()) @@ -65,8 +65,5 @@ def random_values(client): client.send_message("/1/xy1", [random.random(), random.random()]) time.sleep(.5) - start_server(args.serverip, args.serverport) start_client(args.clientip, args.clientport) - - diff --git a/examples/simple_client.py b/examples/simple_client.py index b9141a3..b5f93e3 100644 --- a/examples/simple_client.py +++ b/examples/simple_client.py @@ -7,7 +7,6 @@ import random import time -from pythonosc import osc_message_builder from pythonosc import udp_client if __name__ == "__main__": diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 3864ef5..b3b1fdc 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -7,9 +7,11 @@ ArgValue = Union[str, bytes, bool, int, float, osc_types.MidiPacket, list] + class BuildError(Exception): """Error raised when an incomplete message is trying to be built.""" + class OscMessageBuilder(object): """Builds arbitrary OscMessage instances.""" @@ -79,7 +81,7 @@ def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: if arg_type and not self._valid_type(arg_type): raise ValueError( 'arg_type must be one of {}, or an array of valid types' - .format(self._SUPPORTED_ARG_TYPES)) + .format(self._SUPPORTED_ARG_TYPES)) if not arg_type: arg_type = self._get_arg_type(arg_value) if isinstance(arg_type, list): diff --git a/pythonosc/osc_server.py b/pythonosc/osc_server.py index 9af8890..62f7df9 100644 --- a/pythonosc/osc_server.py +++ b/pythonosc/osc_server.py @@ -55,7 +55,7 @@ def __init__(self, server_address: Tuple[str, int], dispatcher: Dispatcher, bind Args: server_address: IP and port of server dispatcher: Dispatcher this server will use - (optional) bind_and_activate: default=True defines if the server has to start on call of constructor + (optional) bind_and_activate: default=True defines if the server has to start on call of constructor """ super().__init__(server_address, _UDPHandler, bind_and_activate) self._dispatcher = dispatcher diff --git a/pythonosc/parsing/ntp.py b/pythonosc/parsing/ntp.py index 19a699f..419517b 100644 --- a/pythonosc/parsing/ntp.py +++ b/pythonosc/parsing/ntp.py @@ -28,20 +28,18 @@ class NtpError(Exception): - """Base class for ntp module errors.""" + """Base class for ntp module errors.""" def parse_timestamp(timestamp: int) -> Timestamp: - """Parse NTP timestamp as Timetag. - """ + """Parse NTP timestamp as Timetag.""" seconds = timestamp >> 32 fraction = timestamp & 0xFFFFFFFF return Timestamp(seconds, fraction) def ntp_to_system_time(timestamp: bytes) -> float: - """Convert a NTP timestamp to system time in seconds. - """ + """Convert a NTP timestamp to system time in seconds.""" try: timestamp = struct.unpack('>Q', timestamp)[0] except Exception as e: @@ -50,22 +48,19 @@ def ntp_to_system_time(timestamp: bytes) -> float: def system_time_to_ntp(seconds: float) -> bytes: - """Convert a system time in seconds to NTP timestamp. - """ + """Convert a system time in seconds to NTP timestamp.""" try: - seconds = seconds + _NTP_DELTA + seconds = seconds + _NTP_DELTA except TypeError as e: - raise NtpError(e) + raise NtpError(e) return struct.pack('>Q', int(seconds * _SECONDS_TO_NTP_TIMESTAMP)) def ntp_time_to_system_epoch(seconds: float) -> float: - """Convert a NTP time in seconds to system time in seconds. - """ + """Convert a NTP time in seconds to system time in seconds.""" return seconds - _NTP_DELTA def system_time_to_ntp_epoch(seconds: float) -> float: - """Convert a system time in seconds to NTP time in seconds. - """ + """Convert a system time in seconds to NTP time in seconds.""" return seconds + _NTP_DELTA diff --git a/pythonosc/parsing/osc_types.py b/pythonosc/parsing/osc_types.py index 3657953..97361ee 100644 --- a/pythonosc/parsing/osc_types.py +++ b/pythonosc/parsing/osc_types.py @@ -3,7 +3,7 @@ import struct from pythonosc.parsing import ntp -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta from typing import Union, Tuple, cast diff --git a/pythonosc/test/parsing/test_osc_types.py b/pythonosc/test/parsing/test_osc_types.py index faa9ad3..f3a66cb 100644 --- a/pythonosc/test/parsing/test_osc_types.py +++ b/pythonosc/test/parsing/test_osc_types.py @@ -125,7 +125,7 @@ class TestMidi(unittest.TestCase): def test_get_midi(self): cases = { b"\x00\x00\x00\x00": ((0, 0, 0, 0), 4), - b"\x00\x00\x00\x02": ((0, 0, 0, 1), 4), + b"\x00\x00\x00\x01": ((0, 0, 0, 1), 4), b"\x00\x00\x00\x02": ((0, 0, 0, 2), 4), b"\x00\x00\x00\x03": ((0, 0, 0, 3), 4), diff --git a/pythonosc/test/test_dispatcher.py b/pythonosc/test/test_dispatcher.py index 3e9f7ff..5d9d2fd 100644 --- a/pythonosc/test/test_dispatcher.py +++ b/pythonosc/test/test_dispatcher.py @@ -142,11 +142,11 @@ def test_unmap_exception(self): def dummyhandler(): pass - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): self.dispatcher.unmap("/unmap/exception", dummyhandler) handlerobj = self.dispatcher.map("/unmap/somethingelse", dummyhandler()) - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): self.dispatcher.unmap("/unmap/exception", handlerobj) diff --git a/pythonosc/test/test_osc_bundle.py b/pythonosc/test/test_osc_bundle.py index 76cf5f2..f118cdd 100644 --- a/pythonosc/test/test_osc_bundle.py +++ b/pythonosc/test/test_osc_bundle.py @@ -123,7 +123,7 @@ def test_raises_on_invalid_datagram(self): osc_bundle.ParseError, osc_bundle.OscBundle, _DGRAM_INVALID_INDEX) def test_unknown_type(self): - bundle = osc_bundle.OscBundle(_DGRAM_UNKNOWN_TYPE) + osc_bundle.OscBundle(_DGRAM_UNKNOWN_TYPE) if __name__ == "__main__": diff --git a/pythonosc/test/test_udp_client.py b/pythonosc/test/test_udp_client.py index 18c4cb1..f4eb570 100644 --- a/pythonosc/test/test_udp_client.py +++ b/pythonosc/test/test_udp_client.py @@ -1,4 +1,3 @@ -import socket import unittest from unittest import mock diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index aa96106..e08b641 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -15,6 +15,7 @@ from typing import Union + class UDPClient(object): """OSC client to send :class:`OscMessage` or :class:`OscBundle` via UDP""" From 4f649207ea511efe026b5b7592b365f0e29f49d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 00:06:55 +0000 Subject: [PATCH 09/69] fix one more flake8 issue --- examples/simple_2way.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/simple_2way.py b/examples/simple_2way.py index 7ad62db..5356efb 100644 --- a/examples/simple_2way.py +++ b/examples/simple_2way.py @@ -65,5 +65,6 @@ def random_values(client): client.send_message("/1/xy1", [random.random(), random.random()]) time.sleep(.5) + start_server(args.serverip, args.serverport) start_client(args.clientip, args.clientport) From a2f8990bce13f85a10065cee75b4ec9388c16d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 00:26:33 +0000 Subject: [PATCH 10/69] fix more mypy and flake8 warnings --- pythonosc/dispatcher.py | 6 +++--- pythonosc/osc_bundle_builder.py | 6 +++--- pythonosc/osc_packet.py | 2 +- pythonosc/parsing/ntp.py | 4 ++-- pythonosc/test/test_osc_message.py | 8 ++++---- pythonosc/udp_client.py | 9 ++++----- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index ea74480..03b767a 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -32,7 +32,7 @@ def __init__(self, _callback: Callable, _args: Union[Any, List[Any]], # needed for test module def __eq__(self, other: Any) -> bool: - return (type(self) == type(other) and + return (isinstance(self, type(other)) and self.callback == other.callback and self.args == other.args and self.needs_reply_address == other.needs_reply_address) @@ -63,8 +63,8 @@ class Dispatcher(object): """ def __init__(self) -> None: - self._map = collections.defaultdict(list) # type: DefaultDict[str, List[Handler]] - self._default_handler = None # type: Optional[Handler] + self._map: DefaultDict[str, List[Handler]] = collections.defaultdict(list) + self._default_handler: Optional[Handler] = None def map(self, address: str, handler: Callable, *args: Union[Any, List[Any]], needs_reply_address: bool = False) -> Handler: diff --git a/pythonosc/osc_bundle_builder.py b/pythonosc/osc_bundle_builder.py index 4c8878a..a438729 100644 --- a/pythonosc/osc_bundle_builder.py +++ b/pythonosc/osc_bundle_builder.py @@ -25,7 +25,7 @@ def __init__(self, timestamp: int) -> None: seconds since the epoch in UTC or IMMEDIATELY. """ self._timestamp = timestamp - self._contents = [] # type: List[osc_bundle.OscBundle] + self._contents: List[osc_bundle.OscBundle] = [] def add_content(self, content: osc_bundle.OscBundle) -> None: """Add a new content to this bundle. @@ -45,8 +45,8 @@ def build(self) -> osc_bundle.OscBundle: try: dgram += osc_types.write_date(self._timestamp) for content in self._contents: - if (type(content) == osc_message.OscMessage - or type(content) == osc_bundle.OscBundle): + if (isinstance(content, osc_message.OscMessage) + or isinstance(content, osc_bundle.OscBundle)): size = content.size dgram += osc_types.write_int(size) dgram += content.dgram diff --git a/pythonosc/osc_packet.py b/pythonosc/osc_packet.py index 81ff14b..32b88a8 100644 --- a/pythonosc/osc_packet.py +++ b/pythonosc/osc_packet.py @@ -9,7 +9,7 @@ from pythonosc import osc_bundle from pythonosc import osc_message -from typing import Union, List, NamedTuple +from typing import List, NamedTuple # A namedtuple as returned my the _timed_msg_of_bundle function. # 1) the system time at which the message should be executed diff --git a/pythonosc/parsing/ntp.py b/pythonosc/parsing/ntp.py index 419517b..c944fd8 100644 --- a/pythonosc/parsing/ntp.py +++ b/pythonosc/parsing/ntp.py @@ -41,10 +41,10 @@ def parse_timestamp(timestamp: int) -> Timestamp: def ntp_to_system_time(timestamp: bytes) -> float: """Convert a NTP timestamp to system time in seconds.""" try: - timestamp = struct.unpack('>Q', timestamp)[0] + ts = struct.unpack('>Q', timestamp)[0] except Exception as e: raise NtpError(e) - return timestamp * _NTP_TIMESTAMP_TO_SECONDS - _NTP_DELTA + return ts * _NTP_TIMESTAMP_TO_SECONDS - _NTP_DELTA def system_time_to_ntp(seconds: float) -> bytes: diff --git a/pythonosc/test/test_osc_message.py b/pythonosc/test/test_osc_message.py index 2268fb6..efe0076 100644 --- a/pythonosc/test/test_osc_message.py +++ b/pythonosc/test/test_osc_message.py @@ -66,21 +66,21 @@ def test_switch_goes_off(self): msg = osc_message.OscMessage(_DGRAM_SWITCH_GOES_OFF) self.assertEqual("/SYNC", msg.address) self.assertEqual(1, len(msg.params)) - self.assertTrue(type(msg.params[0]) == float) + self.assertTrue(isinstance(msg.params[0], float)) self.assertAlmostEqual(0.0, msg.params[0]) def test_switch_goes_on(self): msg = osc_message.OscMessage(_DGRAM_SWITCH_GOES_ON) self.assertEqual("/SYNC", msg.address) self.assertEqual(1, len(msg.params)) - self.assertTrue(type(msg.params[0]) == float) + self.assertTrue(isinstance(msg.params[0], float)) self.assertAlmostEqual(0.5, msg.params[0]) def test_knob_rotates(self): msg = osc_message.OscMessage(_DGRAM_KNOB_ROTATES) self.assertEqual("/FB", msg.address) self.assertEqual(1, len(msg.params)) - self.assertTrue(type(msg.params[0]) == float) + self.assertTrue(isinstance(msg.params[0], float)) def test_no_params(self): msg = osc_message.OscMessage(_DGRAM_NO_PARAMS) @@ -126,7 +126,7 @@ def test_ignores_unknown_param(self): msg = osc_message.OscMessage(_DGRAM_UNKNOWN_PARAM_TYPE) self.assertEqual("/SYNC", msg.address) self.assertEqual(1, len(msg.params)) - self.assertTrue(type(msg.params[0]) == float) + self.assertTrue(isinstance(msg.params[0], float)) self.assertAlmostEqual(0.5, msg.params[0]) def test_raises_on_invalid_array(self): diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index e08b641..20b0f17 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -68,12 +68,11 @@ def send_message(self, address: str, value: ArgValue) -> None: """ builder = OscMessageBuilder(address=address) if value is None: - values = [] + pass elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): - values = [value] + builder.add_arg(value) else: - values = value - for val in values: - builder.add_arg(val) + for val in value: + builder.add_arg(val) msg = builder.build() self.send(msg) From dca43a9334aeb27c74fe19ebf38b6f68af198867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 00:29:10 +0000 Subject: [PATCH 11/69] ignore bin to exclude default act binary --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ba74660..5c4fe8e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,8 @@ coverage.xml # Sphinx documentation docs/_build/ +# act +bin/ + # PyBuilder target/ From fd0fdd8fd845149f2c8e98944d055999e6b6ee37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 00:34:45 +0000 Subject: [PATCH 12/69] add contributing file --- CONTRIBUTING.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d01b9ce --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +Thanks for contributing to this package! + +Before sending a PR, please make sure you checked the [python test workflow](.github/workflows/python-test.yml) and ran it locally, either using [act](https://nektosact.com) or by executing the workflow actions yourself. + +Please only send the PR once all tests pass and mypy is happy, thanks! \ No newline at end of file From e33494ea39ef6d0173208b7824b5d8f5470d7ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 00:44:43 +0000 Subject: [PATCH 13/69] downscope permission for python test workflow --- .github/workflows/python-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 03963eb..2d8b994 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -3,6 +3,8 @@ name: Test +permissions: read-all + on: push: branches: [ master ] @@ -27,7 +29,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest mypy + python -m pip install flake8 pytest pytest-cov mypy - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -38,3 +40,5 @@ jobs: run: mypy pythonosc examples - name: Test with pytest run: pytest + - name: Coverage with pytest-cov + run: pytest -cov=pythonosc From e7a43615fca0273796954cdc927d7d5e46b6cfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 00:45:11 +0000 Subject: [PATCH 14/69] remove temporarily added pytest-cov plugin --- .github/workflows/python-test.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 2d8b994..72dd0e1 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -29,7 +29,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest pytest-cov mypy + python -m pip install flake8 pytest mypy - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -40,5 +40,3 @@ jobs: run: mypy pythonosc examples - name: Test with pytest run: pytest - - name: Coverage with pytest-cov - run: pytest -cov=pythonosc From 30ef81c6655c7507eb8d9b5bbf7c4679e870250a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 12:52:11 +0000 Subject: [PATCH 15/69] format with black and add gh action to check black format was applied --- .github/workflows/black.yml | 12 ++ .github/workflows/python-test.yml | 2 +- CONTRIBUTING.md | 7 +- docs/conf.py | 60 +++---- examples/async_server.py | 4 +- examples/dispatcher.py | 4 +- examples/simple_2way.py | 23 ++- examples/simple_client.py | 9 +- examples/simple_server.py | 10 +- pythonosc/dispatcher.py | 96 +++++++---- pythonosc/osc_bundle.py | 9 +- pythonosc/osc_bundle_builder.py | 12 +- pythonosc/osc_message.py | 18 +- pythonosc/osc_message_builder.py | 49 ++++-- pythonosc/osc_packet.py | 31 ++-- pythonosc/osc_server.py | 44 +++-- pythonosc/parsing/ntp.py | 21 ++- pythonosc/parsing/osc_types.py | 154 ++++++++--------- pythonosc/test/parsing/test_ntp.py | 2 +- pythonosc/test/parsing/test_osc_types.py | 182 +++++++++++---------- pythonosc/test/test_dispatcher.py | 120 +++++++++----- pythonosc/test/test_osc_bundle.py | 40 ++--- pythonosc/test/test_osc_bundle_builder.py | 6 +- pythonosc/test/test_osc_message.py | 45 ++--- pythonosc/test/test_osc_message_builder.py | 34 ++-- pythonosc/test/test_osc_packet.py | 12 +- pythonosc/test/test_osc_server.py | 34 ++-- pythonosc/test/test_udp_client.py | 23 +-- pythonosc/udp_client.py | 12 +- scripts/print_datagrams_main.py | 11 +- 30 files changed, 635 insertions(+), 451 deletions(-) create mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 0000000..7afeb0b --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,12 @@ +name: Lint with Black + +on: [push, pull_request] + +permissions: read-all + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 72dd0e1..8e7088e 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -35,7 +35,7 @@ jobs: # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + flake8 . --count --exit-zero --max-complexity=10 --statistics - name: Check with mypy run: mypy pythonosc examples - name: Test with pytest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d01b9ce..0146cae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,4 +2,9 @@ Thanks for contributing to this package! Before sending a PR, please make sure you checked the [python test workflow](.github/workflows/python-test.yml) and ran it locally, either using [act](https://nektosact.com) or by executing the workflow actions yourself. -Please only send the PR once all tests pass and mypy is happy, thanks! \ No newline at end of file +TL;DR: +- Format all code with Black +- Provide type annotations with mypy +- Write and run tests with pytest + +Please only send the PR once all of the above is done, thanks! \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index da3c34a..f14a2b2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,18 +17,19 @@ # sys.path.insert(0, os.path.abspath('.')) import os import sys + sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- -project = 'python-osc' -copyright = '2019, attwad' -author = 'attwad' +project = "python-osc" +copyright = "2019, attwad" +author = "attwad" # The short X.Y version -version = '' +version = "" # The full version, including alpha/beta/rc tags -release = '1.7.1' +release = "1.7.1" # -- General configuration --------------------------------------------------- @@ -41,24 +42,24 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.doctest', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', - 'sphinx.ext.napoleon' + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -70,7 +71,7 @@ # 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'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None @@ -81,7 +82,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -92,7 +93,7 @@ # 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_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -108,7 +109,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'python-oscdoc' +htmlhelp_basename = "python-oscdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -117,15 +118,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -135,8 +133,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'python-osc.tex', 'python-osc Documentation', - 'attwad', 'manual'), + (master_doc, "python-osc.tex", "python-osc Documentation", "attwad", "manual"), ] @@ -144,10 +141,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'python-osc', 'python-osc Documentation', - [author], 1) -] +man_pages = [(master_doc, "python-osc", "python-osc Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -156,9 +150,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'python-osc', 'python-osc Documentation', - author, 'python-osc', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "python-osc", + "python-osc Documentation", + author, + "python-osc", + "One line description of project.", + "Miscellaneous", + ), ] @@ -177,7 +177,7 @@ # epub_uid = '' # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] # -- Extension configuration ------------------------------------------------- diff --git a/examples/async_server.py b/examples/async_server.py index 5b724c0..87945eb 100644 --- a/examples/async_server.py +++ b/examples/async_server.py @@ -24,7 +24,9 @@ async def loop(): async def init_main(): server = AsyncIOOSCUDPServer((ip, port), dispatcher, asyncio.get_event_loop()) - transport, protocol = await server.create_serve_endpoint() # Create datagram endpoint and start serving + transport, protocol = ( + await server.create_serve_endpoint() + ) # Create datagram endpoint and start serving await loop() # Enter main loop of program diff --git a/examples/dispatcher.py b/examples/dispatcher.py index 59bb2de..7dc7e3d 100644 --- a/examples/dispatcher.py +++ b/examples/dispatcher.py @@ -29,8 +29,8 @@ def set_filter(address: str, *args: List[Any]) -> None: client = SimpleUDPClient("127.0.0.1", 1337) # Send message and receive exactly one message (blocking) -client.send_message("/filter1", [1., 2.]) +client.send_message("/filter1", [1.0, 2.0]) server.handle_request() -client.send_message("/filter8", [6., -2.]) +client.send_message("/filter8", [6.0, -2.0]) server.handle_request() diff --git a/examples/simple_2way.py b/examples/simple_2way.py index 5356efb..2a7b555 100644 --- a/examples/simple_2way.py +++ b/examples/simple_2way.py @@ -25,9 +25,21 @@ def print_xy_fader_handler(unused_addr, args, value1, value2): if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--serverip", default="127.0.0.1", help="The ip to listen on") - parser.add_argument("--serverport", type=int, default=5005, help="The port the OSC Server is listening on") - parser.add_argument("--clientip", default="127.0.0.1", help="The ip of the OSC server") - parser.add_argument("--clientport", type=int, default=5006, help="The port the OSC Client is listening on") + parser.add_argument( + "--serverport", + type=int, + default=5005, + help="The port the OSC Server is listening on", + ) + parser.add_argument( + "--clientip", default="127.0.0.1", help="The ip of the OSC server" + ) + parser.add_argument( + "--clientport", + type=int, + default=5006, + help="The port the OSC Client is listening on", + ) args = parser.parse_args() # listen to addresses and print changes in values @@ -41,8 +53,7 @@ def print_xy_fader_handler(unused_addr, args, value1, value2): def start_server(ip, port): print("Starting Server") - server = osc_server.ThreadingOSCUDPServer( - (ip, port), dispatcher) + server = osc_server.ThreadingOSCUDPServer((ip, port), dispatcher) print("Serving on {}".format(server.server_address)) thread = threading.Thread(target=server.serve_forever) thread.start() @@ -63,7 +74,7 @@ def random_values(client): client.send_message("/1/fader2", random.random()) client.send_message("/1/fader1", random.random()) client.send_message("/1/xy1", [random.random(), random.random()]) - time.sleep(.5) + time.sleep(0.5) start_server(args.serverip, args.serverport) diff --git a/examples/simple_client.py b/examples/simple_client.py index b5f93e3..26849b6 100644 --- a/examples/simple_client.py +++ b/examples/simple_client.py @@ -3,6 +3,7 @@ This program sends 10 random values between 0.0 and 1.0 to the /filter address, waiting for 1 seconds between each value. """ + import argparse import random import time @@ -11,10 +12,10 @@ if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--ip", default="127.0.0.1", - help="The ip of the OSC server") - parser.add_argument("--port", type=int, default=5005, - help="The port the OSC server is listening on") + parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") + parser.add_argument( + "--port", type=int, default=5005, help="The port the OSC server is listening on" + ) args = parser.parse_args() client = udp_client.SimpleUDPClient(args.ip, args.port) diff --git a/examples/simple_server.py b/examples/simple_server.py index 552fb39..a862457 100644 --- a/examples/simple_server.py +++ b/examples/simple_server.py @@ -3,6 +3,7 @@ This program listens to several addresses, and prints some information about received packets. """ + import argparse import math @@ -23,10 +24,8 @@ def print_compute_handler(unused_addr, args, volume): if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--ip", - default="127.0.0.1", help="The ip to listen on") - parser.add_argument("--port", - type=int, default=5005, help="The port to listen on") + parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") + parser.add_argument("--port", type=int, default=5005, help="The port to listen on") args = parser.parse_args() dispatcher = Dispatcher() @@ -34,7 +33,6 @@ def print_compute_handler(unused_addr, args, volume): dispatcher.map("/volume", print_volume_handler, "Volume") dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) - server = osc_server.ThreadingOSCUDPServer( - (args.ip, args.port), dispatcher) + server = osc_server.ThreadingOSCUDPServer((args.ip, args.port), dispatcher) print("Serving on {}".format(server.server_address)) server.serve_forever() diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index 03b767a..462a0c1 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -6,7 +6,17 @@ import re import time from pythonosc import osc_packet -from typing import overload, List, Union, Any, Generator, Tuple, Callable, Optional, DefaultDict +from typing import ( + overload, + List, + Union, + Any, + Generator, + Tuple, + Callable, + Optional, + DefaultDict, +) from pythonosc.osc_message import OscMessage @@ -18,24 +28,30 @@ class Handler(object): message if any were passed. """ - def __init__(self, _callback: Callable, _args: Union[Any, List[Any]], - _needs_reply_address: bool = False) -> None: + def __init__( + self, + _callback: Callable, + _args: Union[Any, List[Any]], + _needs_reply_address: bool = False, + ) -> None: """ Args: _callback Function that is called when handler is invoked _args: Message causing invocation _needs_reply_address Whether the client's ip address shall be passed as an argument or not - """ + """ self.callback = _callback self.args = _args self.needs_reply_address = _needs_reply_address # needed for test module def __eq__(self, other: Any) -> bool: - return (isinstance(self, type(other)) and - self.callback == other.callback and - self.args == other.args and - self.needs_reply_address == other.needs_reply_address) + return ( + isinstance(self, type(other)) + and self.callback == other.callback + and self.args == other.args + and self.needs_reply_address == other.needs_reply_address + ) def invoke(self, client_address: Tuple[str, int], message: OscMessage) -> None: """Invokes the associated callback function @@ -43,7 +59,7 @@ def invoke(self, client_address: Tuple[str, int], message: OscMessage) -> None: Args: client_address: Address match that causes the invocation message: Message causing invocation - """ + """ if self.needs_reply_address: if self.args: self.callback(client_address, message.address, self.args, *message) @@ -66,8 +82,13 @@ def __init__(self) -> None: self._map: DefaultDict[str, List[Handler]] = collections.defaultdict(list) self._default_handler: Optional[Handler] = None - def map(self, address: str, handler: Callable, *args: Union[Any, List[Any]], - needs_reply_address: bool = False) -> Handler: + def map( + self, + address: str, + handler: Callable, + *args: Union[Any, List[Any]], + needs_reply_address: bool = False, + ) -> Handler: """Map an address to a handler The callback function must have one of the following signatures: @@ -107,8 +128,13 @@ def unmap(self, address: str, handler: Handler) -> None: pass @overload - def unmap(self, address: str, handler: Callable, *args: Union[Any, List[Any]], - needs_reply_address: bool = False) -> None: + def unmap( + self, + address: str, + handler: Callable, + *args: Union[Any, List[Any]], + needs_reply_address: bool = False, + ) -> None: """Remove an already mapped handler from an address Args: @@ -127,12 +153,19 @@ def unmap(self, address, handler, *args, needs_reply_address=False): if isinstance(handler, Handler): self._map[address].remove(handler) else: - self._map[address].remove(Handler(handler, list(args), needs_reply_address)) + self._map[address].remove( + Handler(handler, list(args), needs_reply_address) + ) except ValueError as e: if str(e) == "list.remove(x): x not in list": - raise ValueError("Address '%s' doesn't have handler '%s' mapped to it" % (address, handler)) from e - - def handlers_for_address(self, address_pattern: str) -> Generator[Handler, None, None]: + raise ValueError( + "Address '%s' doesn't have handler '%s' mapped to it" + % (address, handler) + ) from e + + def handlers_for_address( + self, address_pattern: str + ) -> Generator[Handler, None, None]: """Yields handlers matching an address @@ -147,27 +180,31 @@ def handlers_for_address(self, address_pattern: str) -> Generator[Handler, None, # Let's consider numbers and _ "characters" too here, it's not said # explicitly in the specification but it sounds good. escaped_address_pattern = re.escape(address_pattern) - pattern = escaped_address_pattern.replace('\\?', '\\w?') + pattern = escaped_address_pattern.replace("\\?", "\\w?") # '*' in the OSC Address Pattern matches any sequence of zero or more # characters. - pattern = pattern.replace('\\*', '[\\w|\\+]*') + pattern = pattern.replace("\\*", "[\\w|\\+]*") # The rest of the syntax in the specification is like the re module so # we're fine. - pattern = pattern + '$' + pattern = pattern + "$" patterncompiled = re.compile(pattern) matched = False for addr, handlers in self._map.items(): - if (patterncompiled.match(addr) - or (('*' in addr) and re.match(addr.replace('*', '[^/]*?/*'), address_pattern))): + if patterncompiled.match(addr) or ( + ("*" in addr) + and re.match(addr.replace("*", "[^/]*?/*"), address_pattern) + ): yield from handlers matched = True if not matched and self._default_handler: - logging.debug('No handler matched but default handler present, added it.') + logging.debug("No handler matched but default handler present, added it.") yield self._default_handler - def call_handlers_for_packet(self, data: bytes, client_address: Tuple[str, int]) -> None: + def call_handlers_for_packet( + self, data: bytes, client_address: Tuple[str, int] + ) -> None: """Invoke handlers for all messages in OSC packet The incoming OSC Packet is decoded and the handlers for each included message is found and invoked. @@ -182,8 +219,7 @@ def call_handlers_for_packet(self, data: bytes, client_address: Tuple[str, int]) packet = osc_packet.OscPacket(data) for timed_msg in packet.messages: now = time.time() - handlers = self.handlers_for_address( - timed_msg.message.address) + handlers = self.handlers_for_address(timed_msg.message.address) if not handlers: continue # If the message is to be handled later, then so be it. @@ -194,7 +230,9 @@ def call_handlers_for_packet(self, data: bytes, client_address: Tuple[str, int]) except osc_packet.ParseError: pass - def set_default_handler(self, handler: Callable, needs_reply_address: bool = False) -> None: + def set_default_handler( + self, handler: Callable, needs_reply_address: bool = False + ) -> None: """Sets the default handler The default handler is invoked every time no other handler is mapped to an address. @@ -203,4 +241,6 @@ def set_default_handler(self, handler: Callable, needs_reply_address: bool = Fal handler: Callback function to handle unmapped requests needs_reply_address: Whether the callback shall be passed the client address """ - self._default_handler = None if (handler is None) else Handler(handler, [], needs_reply_address) + self._default_handler = ( + None if (handler is None) else Handler(handler, [], needs_reply_address) + ) diff --git a/pythonosc/osc_bundle.py b/pythonosc/osc_bundle.py index 274bef6..9598624 100644 --- a/pythonosc/osc_bundle.py +++ b/pythonosc/osc_bundle.py @@ -37,7 +37,9 @@ def __init__(self, dgram: bytes) -> None: # Get the contents as a list of OscBundle and OscMessage. self._contents = self._parse_contents(index) - def _parse_contents(self, index: int) -> List[Union['OscBundle', osc_message.OscMessage]]: + def _parse_contents( + self, index: int + ) -> List[Union["OscBundle", osc_message.OscMessage]]: contents = [] # type: List[Union[OscBundle, osc_message.OscMessage]] try: @@ -49,7 +51,7 @@ def _parse_contents(self, index: int) -> List[Union['OscBundle', osc_message.Osc # Get the sub content size. content_size, index = osc_types.get_int(self._dgram, index) # Get the datagram for the sub content. - content_dgram = self._dgram[index:index + content_size] + content_dgram = self._dgram[index : index + content_size] # Increment our position index up to the next possible content. index += content_size # Parse the content into an OSC message or bundle. @@ -59,7 +61,8 @@ def _parse_contents(self, index: int) -> List[Union['OscBundle', osc_message.Osc contents.append(osc_message.OscMessage(content_dgram)) else: logging.warning( - "Could not identify content type of dgram %r" % content_dgram) + "Could not identify content type of dgram %r" % content_dgram + ) except (osc_types.ParseError, osc_message.ParseError, IndexError) as e: raise ParseError("Could not parse a content datagram: %s" % e) diff --git a/pythonosc/osc_bundle_builder.py b/pythonosc/osc_bundle_builder.py index a438729..2779269 100644 --- a/pythonosc/osc_bundle_builder.py +++ b/pythonosc/osc_bundle_builder.py @@ -41,19 +41,21 @@ def build(self) -> osc_bundle.OscBundle: Raises: - BuildError: if we could not build the bundle. """ - dgram = b'#bundle\x00' + dgram = b"#bundle\x00" try: dgram += osc_types.write_date(self._timestamp) for content in self._contents: - if (isinstance(content, osc_message.OscMessage) - or isinstance(content, osc_bundle.OscBundle)): + if isinstance(content, osc_message.OscMessage) or isinstance( + content, osc_bundle.OscBundle + ): size = content.size dgram += osc_types.write_int(size) dgram += content.dgram else: raise BuildError( "Content must be either OscBundle or OscMessage" - "found {}".format(type(content))) + "found {}".format(type(content)) + ) return osc_bundle.OscBundle(dgram) except osc_types.BuildError as be: - raise BuildError('Could not build the bundle {}'.format(be)) + raise BuildError("Could not build the bundle {}".format(be)) diff --git a/pythonosc/osc_message.py b/pythonosc/osc_message.py index 55b0a0e..3d46551 100644 --- a/pythonosc/osc_message.py +++ b/pythonosc/osc_message.py @@ -31,7 +31,7 @@ def _parse_datagram(self) -> None: # Get the parameters types. type_tag, index = osc_types.get_string(self._dgram, index) - if type_tag.startswith(','): + if type_tag.startswith(","): type_tag = type_tag[1:] params = [] # type: List[Any] @@ -69,19 +69,25 @@ def _parse_datagram(self) -> None: param_stack.append(array) elif param == "]": # Array stop. if len(param_stack) < 2: - raise ParseError('Unexpected closing bracket in type tag: {0}'.format(type_tag)) + raise ParseError( + "Unexpected closing bracket in type tag: {0}".format( + type_tag + ) + ) param_stack.pop() # TODO: Support more exotic types as described in the specification. else: - logging.warning('Unhandled parameter type: {0}'.format(param)) + logging.warning("Unhandled parameter type: {0}".format(param)) continue if param not in "[]": param_stack[-1].append(val) if len(param_stack) != 1: - raise ParseError('Missing closing bracket in type tag: {0}'.format(type_tag)) + raise ParseError( + "Missing closing bracket in type tag: {0}".format(type_tag) + ) self._parameters = params except osc_types.ParseError as pe: - raise ParseError('Found incorrect datagram, ignoring it', pe) + raise ParseError("Found incorrect datagram, ignoring it", pe) @property def address(self) -> str: @@ -91,7 +97,7 @@ def address(self) -> str: @staticmethod def dgram_is_message(dgram: bytes) -> bool: """Returns whether this datagram starts as an OSC message.""" - return dgram.startswith(b'/') + return dgram.startswith(b"/") @property def size(self) -> int: diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index b3b1fdc..e91b76c 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -31,8 +31,18 @@ class OscMessageBuilder(object): ARG_TYPE_ARRAY_STOP = "]" _SUPPORTED_ARG_TYPES = ( - ARG_TYPE_FLOAT, ARG_TYPE_DOUBLE, ARG_TYPE_INT, ARG_TYPE_INT64, ARG_TYPE_BLOB, ARG_TYPE_STRING, - ARG_TYPE_RGBA, ARG_TYPE_MIDI, ARG_TYPE_TRUE, ARG_TYPE_FALSE, ARG_TYPE_NIL) + ARG_TYPE_FLOAT, + ARG_TYPE_DOUBLE, + ARG_TYPE_INT, + ARG_TYPE_INT64, + ARG_TYPE_BLOB, + ARG_TYPE_STRING, + ARG_TYPE_RGBA, + ARG_TYPE_MIDI, + ARG_TYPE_TRUE, + ARG_TYPE_FALSE, + ARG_TYPE_NIL, + ) def __init__(self, address: Optional[str] = None) -> None: """Initialize a new builder for a message. @@ -80,8 +90,10 @@ def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: """ if arg_type and not self._valid_type(arg_type): raise ValueError( - 'arg_type must be one of {}, or an array of valid types' - .format(self._SUPPORTED_ARG_TYPES)) + "arg_type must be one of {}, or an array of valid types".format( + self._SUPPORTED_ARG_TYPES + ) + ) if not arg_type: arg_type = self._get_arg_type(arg_value) if isinstance(arg_type, list): @@ -124,7 +136,7 @@ def _get_arg_type(self, arg_value: ArgValue) -> Union[str, Any]: elif arg_value is None: arg_type = self.ARG_TYPE_NIL else: - raise ValueError('Infered arg_value type is not supported') + raise ValueError("Infered arg_value type is not supported") return arg_type def build(self) -> osc_message.OscMessage: @@ -138,18 +150,18 @@ def build(self) -> osc_message.OscMessage: - an osc_message.OscMessage instance. """ if not self._address: - raise BuildError('OSC addresses cannot be empty') - dgram = b'' + raise BuildError("OSC addresses cannot be empty") + dgram = b"" try: # Write the address. dgram += osc_types.write_string(self._address) if not self._args: - dgram += osc_types.write_string(',') + dgram += osc_types.write_string(",") return osc_message.OscMessage(dgram) # Write the parameters. arg_types = "".join([arg[0] for arg in self._args]) - dgram += osc_types.write_string(',' + arg_types) + dgram += osc_types.write_string("," + arg_types) for arg_type, value in self._args: if arg_type == self.ARG_TYPE_STRING: dgram += osc_types.write_string(value) # type: ignore[arg-type] @@ -167,16 +179,19 @@ def build(self) -> osc_message.OscMessage: dgram += osc_types.write_rgba(value) # type: ignore[arg-type] elif arg_type == self.ARG_TYPE_MIDI: dgram += osc_types.write_midi(value) # type: ignore[arg-type] - elif arg_type in (self.ARG_TYPE_TRUE, - self.ARG_TYPE_FALSE, - self.ARG_TYPE_ARRAY_START, - self.ARG_TYPE_ARRAY_STOP, - self.ARG_TYPE_NIL): + elif arg_type in ( + self.ARG_TYPE_TRUE, + self.ARG_TYPE_FALSE, + self.ARG_TYPE_ARRAY_START, + self.ARG_TYPE_ARRAY_STOP, + self.ARG_TYPE_NIL, + ): continue else: - raise BuildError('Incorrect parameter type found {}'.format( - arg_type)) + raise BuildError( + "Incorrect parameter type found {}".format(arg_type) + ) return osc_message.OscMessage(dgram) except osc_types.BuildError as be: - raise BuildError('Could not build the message: {}'.format(be)) + raise BuildError("Could not build the message: {}".format(be)) diff --git a/pythonosc/osc_packet.py b/pythonosc/osc_packet.py index 32b88a8..9589c90 100644 --- a/pythonosc/osc_packet.py +++ b/pythonosc/osc_packet.py @@ -15,18 +15,23 @@ # 1) the system time at which the message should be executed # in seconds since the epoch. # 2) the actual message. -TimedMessage = NamedTuple('TimedMessage', [ - ('time', float), - ('message', osc_message.OscMessage), -]) - - -def _timed_msg_of_bundle(bundle: osc_bundle.OscBundle, now: float) -> List[TimedMessage]: +TimedMessage = NamedTuple( + "TimedMessage", + [ + ("time", float), + ("message", osc_message.OscMessage), + ], +) + + +def _timed_msg_of_bundle( + bundle: osc_bundle.OscBundle, now: float +) -> List[TimedMessage]: """Returns messages contained in nested bundles as a list of TimedMessage.""" msgs = [] for content in bundle: if type(content) is osc_message.OscMessage: - if (bundle.timestamp == osc_types.IMMEDIATELY or bundle.timestamp < now): + if bundle.timestamp == osc_types.IMMEDIATELY or bundle.timestamp < now: msgs.append(TimedMessage(now, content)) else: msgs.append(TimedMessage(bundle.timestamp, content)) @@ -60,16 +65,18 @@ def __init__(self, dgram: bytes) -> None: if osc_bundle.OscBundle.dgram_is_bundle(dgram): self._messages = sorted( _timed_msg_of_bundle(osc_bundle.OscBundle(dgram), now), - key=lambda x: x.time) + key=lambda x: x.time, + ) elif osc_message.OscMessage.dgram_is_message(dgram): self._messages = [TimedMessage(now, osc_message.OscMessage(dgram))] else: # Empty packet, should not happen as per the spec but heh, UDP... raise ParseError( - 'OSC Packet should at least contain an OscMessage or an ' - 'OscBundle.') + "OSC Packet should at least contain an OscMessage or an " + "OscBundle." + ) except (osc_bundle.ParseError, osc_message.ParseError) as pe: - raise ParseError('Could not parse packet %s' % pe) + raise ParseError("Could not parse packet %s" % pe) @property def messages(self) -> List[TimedMessage]: diff --git a/pythonosc/osc_server.py b/pythonosc/osc_server.py index 62f7df9..7de729e 100644 --- a/pythonosc/osc_server.py +++ b/pythonosc/osc_server.py @@ -39,17 +39,24 @@ def _is_valid_request(request: _RequestType) -> bool: Returns: True if request is OSC bundle or OSC message """ - assert isinstance(request, tuple) # TODO: handle requests which are passed just as a socket? + assert isinstance( + request, tuple + ) # TODO: handle requests which are passed just as a socket? data = request[0] - return ( - osc_bundle.OscBundle.dgram_is_bundle(data) - or osc_message.OscMessage.dgram_is_message(data)) + return osc_bundle.OscBundle.dgram_is_bundle( + data + ) or osc_message.OscMessage.dgram_is_message(data) class OSCUDPServer(socketserver.UDPServer): """Superclass for different flavors of OSC UDP servers""" - def __init__(self, server_address: Tuple[str, int], dispatcher: Dispatcher, bind_and_activate: bool = True) -> None: + def __init__( + self, + server_address: Tuple[str, int], + dispatcher: Dispatcher, + bind_and_activate: bool = True, + ) -> None: """Initialize Args: @@ -60,7 +67,9 @@ def __init__(self, server_address: Tuple[str, int], dispatcher: Dispatcher, bind super().__init__(server_address, _UDPHandler, bind_and_activate) self._dispatcher = dispatcher - def verify_request(self, request: _RequestType, client_address: _AddressType) -> bool: + def verify_request( + self, request: _RequestType, client_address: _AddressType + ) -> bool: """Returns true if the data looks like a valid OSC UDP datagram Args: @@ -95,6 +104,7 @@ class ThreadingOSCUDPServer(socketserver.ThreadingMixIn, OSCUDPServer): if hasattr(os, "fork"): + class ForkingOSCUDPServer(socketserver.ForkingMixIn, OSCUDPServer): """Forking version of the OSC UDP server. @@ -104,13 +114,18 @@ class ForkingOSCUDPServer(socketserver.ForkingMixIn, OSCUDPServer): """ -class AsyncIOOSCUDPServer(): +class AsyncIOOSCUDPServer: """Asynchronous OSC Server An asynchronous OSC Server using UDP. It creates a datagram endpoint that runs in an event loop. """ - def __init__(self, server_address: Tuple[str, int], dispatcher: Dispatcher, loop: BaseEventLoop) -> None: + def __init__( + self, + server_address: Tuple[str, int], + dispatcher: Dispatcher, + loop: BaseEventLoop, + ) -> None: """Initialize Args: @@ -130,7 +145,9 @@ class _OSCProtocolFactory(asyncio.DatagramProtocol): def __init__(self, dispatcher: Dispatcher) -> None: self.dispatcher = dispatcher - def datagram_received(self, data: bytes, client_address: Tuple[str, int]) -> None: + def datagram_received( + self, data: bytes, client_address: Tuple[str, int] + ) -> None: self.dispatcher.call_handlers_for_packet(data, client_address) def serve(self) -> None: @@ -141,7 +158,11 @@ def serve(self) -> None: """ self._loop.run_until_complete(self.create_serve_endpoint()) - def create_serve_endpoint(self) -> Coroutine[Any, Any, Tuple[asyncio.transports.BaseTransport, asyncio.DatagramProtocol]]: + def create_serve_endpoint( + self, + ) -> Coroutine[ + Any, Any, Tuple[asyncio.transports.BaseTransport, asyncio.DatagramProtocol] + ]: """Creates a datagram endpoint and registers it with event loop as coroutine. Returns: @@ -149,7 +170,8 @@ def create_serve_endpoint(self) -> Coroutine[Any, Any, Tuple[asyncio.transports. """ return self._loop.create_datagram_endpoint( lambda: self._OSCProtocolFactory(self.dispatcher), - local_addr=self._server_address) + local_addr=self._server_address, + ) @property def dispatcher(self) -> Dispatcher: diff --git a/pythonosc/parsing/ntp.py b/pythonosc/parsing/ntp.py index c944fd8..068a82a 100644 --- a/pythonosc/parsing/ntp.py +++ b/pythonosc/parsing/ntp.py @@ -8,11 +8,11 @@ # 63 zero bits followed by a one in the least signifigant bit is a special # case meaning "immediately." -IMMEDIATELY = struct.pack('>Q', 1) +IMMEDIATELY = struct.pack(">Q", 1) # timetag * (1 / 2 ** 32) == l32bits + (r32bits / 1 ** 32) -_NTP_TIMESTAMP_TO_SECONDS = 1. / 2. ** 32. -_SECONDS_TO_NTP_TIMESTAMP = 2. ** 32. +_NTP_TIMESTAMP_TO_SECONDS = 1.0 / 2.0**32.0 +_SECONDS_TO_NTP_TIMESTAMP = 2.0**32.0 # From NTP lib. _SYSTEM_EPOCH = datetime.date(*time.gmtime(0)[0:3]) @@ -21,10 +21,13 @@ _NTP_DELTA = (_SYSTEM_EPOCH - _NTP_EPOCH).days * 24 * 3600 -Timestamp = NamedTuple('Timestamp', [ - ('seconds', int), - ('fraction', int), -]) +Timestamp = NamedTuple( + "Timestamp", + [ + ("seconds", int), + ("fraction", int), + ], +) class NtpError(Exception): @@ -41,7 +44,7 @@ def parse_timestamp(timestamp: int) -> Timestamp: def ntp_to_system_time(timestamp: bytes) -> float: """Convert a NTP timestamp to system time in seconds.""" try: - ts = struct.unpack('>Q', timestamp)[0] + ts = struct.unpack(">Q", timestamp)[0] except Exception as e: raise NtpError(e) return ts * _NTP_TIMESTAMP_TO_SECONDS - _NTP_DELTA @@ -53,7 +56,7 @@ def system_time_to_ntp(seconds: float) -> bytes: seconds = seconds + _NTP_DELTA except TypeError as e: raise NtpError(e) - return struct.pack('>Q', int(seconds * _SECONDS_TO_NTP_TIMESTAMP)) + return struct.pack(">Q", int(seconds * _SECONDS_TO_NTP_TIMESTAMP)) def ntp_time_to_system_epoch(seconds: float) -> float: diff --git a/pythonosc/parsing/osc_types.py b/pythonosc/parsing/osc_types.py index 97361ee..5d0ba48 100644 --- a/pythonosc/parsing/osc_types.py +++ b/pythonosc/parsing/osc_types.py @@ -31,7 +31,7 @@ class BuildError(Exception): # Strings and blob dgram length is always a multiple of 4 bytes. _STRING_DGRAM_PAD = 4 _BLOB_DGRAM_PAD = 4 -_EMPTY_STR_DGRAM = b'\x00\x00\x00\x00' +_EMPTY_STR_DGRAM = b"\x00\x00\x00\x00" def write_string(val: str) -> bytes: @@ -41,11 +41,11 @@ def write_string(val: str) -> bytes: - BuildError if the string could not be encoded. """ try: - dgram = val.encode('utf-8') # Default, but better be explicit. + dgram = val.encode("utf-8") # Default, but better be explicit. except (UnicodeEncodeError, AttributeError) as e: - raise BuildError('Incorrect string, could not encode {}'.format(e)) + raise BuildError("Incorrect string, could not encode {}".format(e)) diff = _STRING_DGRAM_PAD - (len(dgram) % _STRING_DGRAM_PAD) - dgram += (b'\x00' * diff) + dgram += b"\x00" * diff return dgram @@ -68,29 +68,31 @@ def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]: ParseError if the datagram could not be parsed. """ if start_index < 0: - raise ParseError('start_index < 0') + raise ParseError("start_index < 0") offset = 0 try: - if (len(dgram) > start_index + _STRING_DGRAM_PAD - and dgram[start_index + _STRING_DGRAM_PAD] == _EMPTY_STR_DGRAM): - return '', start_index + _STRING_DGRAM_PAD + if ( + len(dgram) > start_index + _STRING_DGRAM_PAD + and dgram[start_index + _STRING_DGRAM_PAD] == _EMPTY_STR_DGRAM + ): + return "", start_index + _STRING_DGRAM_PAD while dgram[start_index + offset] != 0: offset += 1 # Align to a byte word. if (offset) % _STRING_DGRAM_PAD == 0: offset += _STRING_DGRAM_PAD else: - offset += (-offset % _STRING_DGRAM_PAD) + offset += -offset % _STRING_DGRAM_PAD # Python slices do not raise an IndexError past the last index, # do it ourselves. if offset > len(dgram[start_index:]): - raise ParseError('Datagram is too short') - data_str = dgram[start_index:start_index + offset] - return data_str.replace(b'\x00', b'').decode('utf-8'), start_index + offset + raise ParseError("Datagram is too short") + data_str = dgram[start_index : start_index + offset] + return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset except IndexError as ie: - raise ParseError('Could not parse datagram %s' % ie) + raise ParseError("Could not parse datagram %s" % ie) except TypeError as te: - raise ParseError('Could not parse datagram %s' % te) + raise ParseError("Could not parse datagram %s" % te) def write_int(val: int) -> bytes: @@ -100,9 +102,9 @@ def write_int(val: int) -> bytes: - BuildError if the int could not be converted. """ try: - return struct.pack('>i', val) + return struct.pack(">i", val) except struct.error as e: - raise BuildError('Wrong argument value passed: {}'.format(e)) + raise BuildError("Wrong argument value passed: {}".format(e)) def get_int(dgram: bytes, start_index: int) -> Tuple[int, int]: @@ -120,13 +122,13 @@ def get_int(dgram: bytes, start_index: int) -> Tuple[int, int]: """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>i', - dgram[start_index:start_index + _INT_DGRAM_LEN])[0], - start_index + _INT_DGRAM_LEN) + struct.unpack(">i", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], + start_index + _INT_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError("Could not parse datagram %s" % e) def write_int64(val: int) -> bytes: @@ -136,9 +138,9 @@ def write_int64(val: int) -> bytes: - BuildError if the int64 could not be converted. """ try: - return struct.pack('>q', val) + return struct.pack(">q", val) except struct.error as e: - raise BuildError('Wrong argument value passed: {}'.format(e)) + raise BuildError("Wrong argument value passed: {}".format(e)) def get_int64(dgram: bytes, start_index: int) -> Tuple[int, int]: @@ -156,13 +158,13 @@ def get_int64(dgram: bytes, start_index: int) -> Tuple[int, int]: """ try: if len(dgram[start_index:]) < _INT64_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>q', - dgram[start_index:start_index + _INT64_DGRAM_LEN])[0], - start_index + _INT64_DGRAM_LEN) + struct.unpack(">q", dgram[start_index : start_index + _INT64_DGRAM_LEN])[0], + start_index + _INT64_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError("Could not parse datagram %s" % e) def get_uint64(dgram: bytes, start_index: int) -> Tuple[int, int]: @@ -180,13 +182,15 @@ def get_uint64(dgram: bytes, start_index: int) -> Tuple[int, int]: """ try: if len(dgram[start_index:]) < _UINT64_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>Q', - dgram[start_index:start_index + _UINT64_DGRAM_LEN])[0], - start_index + _UINT64_DGRAM_LEN) + struct.unpack(">Q", dgram[start_index : start_index + _UINT64_DGRAM_LEN])[ + 0 + ], + start_index + _UINT64_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError("Could not parse datagram %s" % e) def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], int]: @@ -205,7 +209,7 @@ def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], i """ try: if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") timetag, _ = get_uint64(dgram, start_index) seconds, fraction = ntp.parse_timestamp(timetag) @@ -213,12 +217,13 @@ def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], i hours, seconds = seconds // 3600, seconds % 3600 minutes, seconds = seconds // 60, seconds % 60 - utc = (datetime.combine(ntp._NTP_EPOCH, datetime.min.time()) + - timedelta(hours=hours, minutes=minutes, seconds=seconds)) + utc = datetime.combine(ntp._NTP_EPOCH, datetime.min.time()) + timedelta( + hours=hours, minutes=minutes, seconds=seconds + ) return (utc, fraction), start_index + _TIMETAG_DGRAM_LEN except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError("Could not parse datagram %s" % e) def write_float(val: float) -> bytes: @@ -228,9 +233,9 @@ def write_float(val: float) -> bytes: - BuildError if the float could not be converted. """ try: - return struct.pack('>f', val) + return struct.pack(">f", val) except struct.error as e: - raise BuildError('Wrong argument value passed: {}'.format(e)) + raise BuildError("Wrong argument value passed: {}".format(e)) def get_float(dgram: bytes, start_index: int) -> Tuple[float, int]: @@ -251,13 +256,13 @@ def get_float(dgram: bytes, start_index: int) -> Tuple[float, int]: # Noticed that Reaktor doesn't send the last bunch of \x00 needed to make # the float representation complete in some cases, thus we pad here to # account for that. - dgram = dgram + b'\x00' * (_FLOAT_DGRAM_LEN - len(dgram[start_index:])) + dgram = dgram + b"\x00" * (_FLOAT_DGRAM_LEN - len(dgram[start_index:])) return ( - struct.unpack('>f', - dgram[start_index:start_index + _FLOAT_DGRAM_LEN])[0], - start_index + _FLOAT_DGRAM_LEN) + struct.unpack(">f", dgram[start_index : start_index + _FLOAT_DGRAM_LEN])[0], + start_index + _FLOAT_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError("Could not parse datagram %s" % e) def write_double(val: float) -> bytes: @@ -267,9 +272,9 @@ def write_double(val: float) -> bytes: - BuildError if the double could not be converted. """ try: - return struct.pack('>d', val) + return struct.pack(">d", val) except struct.error as e: - raise BuildError('Wrong argument value passed: {}'.format(e)) + raise BuildError("Wrong argument value passed: {}".format(e)) def get_double(dgram: bytes, start_index: int) -> Tuple[float, int]: @@ -287,17 +292,19 @@ def get_double(dgram: bytes, start_index: int) -> Tuple[float, int]: """ try: if len(dgram[start_index:]) < _DOUBLE_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>d', - dgram[start_index:start_index + _DOUBLE_DGRAM_LEN])[0], - start_index + _DOUBLE_DGRAM_LEN) + struct.unpack(">d", dgram[start_index : start_index + _DOUBLE_DGRAM_LEN])[ + 0 + ], + start_index + _DOUBLE_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram {}'.format(e)) + raise ParseError("Could not parse datagram {}".format(e)) def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]: - """ Get a blob from the datagram. + """Get a blob from the datagram. According to the specifications, a blob is made of "an int32 size count, followed by that many 8-bit bytes of arbitrary @@ -319,8 +326,8 @@ def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]: total_size = size + (-size % _BLOB_DGRAM_PAD) end_index = int_offset + size if end_index - start_index > len(dgram[start_index:]): - raise ParseError('Datagram is too short.') - return dgram[int_offset:int_offset + size], int_offset + total_size + raise ParseError("Datagram is too short.") + return dgram[int_offset : int_offset + size], int_offset + total_size def write_blob(val: bytes) -> bytes: @@ -330,11 +337,11 @@ def write_blob(val: bytes) -> bytes: - BuildError if the value was empty or if its size didn't fit an OSC int. """ if not val: - raise BuildError('Blob value cannot be empty') + raise BuildError("Blob value cannot be empty") dgram = write_int(len(val)) dgram += val while len(dgram) % _BLOB_DGRAM_PAD != 0: - dgram += b'\x00' + dgram += b"\x00" return dgram @@ -358,10 +365,10 @@ def get_date(dgram: bytes, start_index: int) -> Tuple[float, int]: ParseError if the datagram could not be parsed. """ # Check for the special case first. - if dgram[start_index:start_index + _TIMETAG_DGRAM_LEN] == ntp.IMMEDIATELY: + if dgram[start_index : start_index + _TIMETAG_DGRAM_LEN] == ntp.IMMEDIATELY: return IMMEDIATELY, start_index + _TIMETAG_DGRAM_LEN if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") timetag, start_index = get_uint64(dgram, start_index) seconds = timetag * ntp._NTP_TIMESTAMP_TO_SECONDS return ntp.ntp_time_to_system_epoch(seconds), start_index @@ -384,9 +391,9 @@ def write_rgba(val: bytes) -> bytes: - BuildError if the int could not be converted. """ try: - return struct.pack('>I', val) + return struct.pack(">I", val) except struct.error as e: - raise BuildError('Wrong argument value passed: {}'.format(e)) + raise BuildError("Wrong argument value passed: {}".format(e)) def get_rgba(dgram: bytes, start_index: int) -> Tuple[bytes, int]: @@ -404,13 +411,13 @@ def get_rgba(dgram: bytes, start_index: int) -> Tuple[bytes, int]: """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>I', - dgram[start_index:start_index + _INT_DGRAM_LEN])[0], - start_index + _INT_DGRAM_LEN) + struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], + start_index + _INT_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError("Could not parse datagram %s" % e) def write_midi(val: MidiPacket) -> bytes: @@ -423,12 +430,12 @@ def write_midi(val: MidiPacket) -> bytes: """ if len(val) != 4: - raise BuildError('MIDI message length is invalid') + raise BuildError("MIDI message length is invalid") try: value = sum((value & 0xFF) << 8 * (3 - pos) for pos, value in enumerate(val)) - return struct.pack('>I', value) + return struct.pack(">I", value) except struct.error as e: - raise BuildError('Wrong argument value passed: {}'.format(e)) + raise BuildError("Wrong argument value passed: {}".format(e)) def get_midi(dgram: bytes, start_index: int) -> Tuple[MidiPacket, int]: @@ -446,12 +453,11 @@ def get_midi(dgram: bytes, start_index: int) -> Tuple[MidiPacket, int]: """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError('Datagram is too short') - val = struct.unpack('>I', - dgram[start_index:start_index + _INT_DGRAM_LEN])[0] + raise ParseError("Datagram is too short") + val = struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0] midi_msg = cast( - MidiPacket, - tuple((val & 0xFF << 8 * i) >> 8 * i for i in range(3, -1, -1))) + MidiPacket, tuple((val & 0xFF << 8 * i) >> 8 * i for i in range(3, -1, -1)) + ) return (midi_msg, start_index + _INT_DGRAM_LEN) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError("Could not parse datagram %s" % e) diff --git a/pythonosc/test/parsing/test_ntp.py b/pythonosc/test/parsing/test_ntp.py index ce8cae0..4bbfd2b 100644 --- a/pythonosc/test/parsing/test_ntp.py +++ b/pythonosc/test/parsing/test_ntp.py @@ -5,7 +5,7 @@ class TestNTP(unittest.TestCase): - """ TODO: Write real tests for this when I get time...""" + """TODO: Write real tests for this when I get time...""" def test_nto_to_system_time(self): unix_time = time.time() diff --git a/pythonosc/test/parsing/test_osc_types.py b/pythonosc/test/parsing/test_osc_types.py index f3a66cb..052a161 100644 --- a/pythonosc/test/parsing/test_osc_types.py +++ b/pythonosc/test/parsing/test_osc_types.py @@ -1,4 +1,5 @@ """Unit tests for the osc_types module.""" + import unittest from pythonosc.parsing import ntp @@ -14,9 +15,8 @@ def test_get_string(self): b"AB\x00\x00": ("AB", 4), b"ABC\x00": ("ABC", 4), b"ABCD\x00\x00\x00\x00": ("ABCD", 8), - b"ABCD\x00\x00\x00\x00GARBAGE": ("ABCD", 8), - b'\x00\x00\x00\x00': ("", 4), + b"\x00\x00\x00\x00": ("", 4), } for dgram, expected in cases.items(): @@ -24,24 +24,21 @@ def test_get_string(self): def test_get_string_raises_on_wrong_dgram(self): cases = [ - b'blablaba', - b'', - b'\x00', - b'\x00\x00', + b"blablaba", + b"", + b"\x00", + b"\x00\x00", True, ] for case in cases: - self.assertRaises( - osc_types.ParseError, osc_types.get_string, case, 0) + self.assertRaises(osc_types.ParseError, osc_types.get_string, case, 0) def test_get_string_raises_when_datagram_too_short(self): - self.assertRaises( - osc_types.ParseError, osc_types.get_string, b'abc\x00', 1) + self.assertRaises(osc_types.ParseError, osc_types.get_string, b"abc\x00", 1) def test_get_string_raises_on_wrong_start_index_negative(self): - self.assertRaises( - osc_types.ParseError, osc_types.get_string, b'abc\x00', -1) + self.assertRaises(osc_types.ParseError, osc_types.get_string, b"abc\x00", -1) class TestInteger(unittest.TestCase): @@ -51,34 +48,33 @@ def test_get_integer(self): b"\x00\x00\x00\x01": (1, 4), b"\x00\x00\x00\x02": (2, 4), b"\x00\x00\x00\x03": (3, 4), - b"\x00\x00\x01\x00": (256, 4), b"\x00\x01\x00\x00": (65536, 4), b"\x01\x00\x00\x00": (16777216, 4), - b"\x00\x00\x00\x01GARBAGE": (1, 4), } for dgram, expected in cases.items(): - self.assertEqual( - expected, osc_types.get_int(dgram, 0)) + self.assertEqual(expected, osc_types.get_int(dgram, 0)) def test_get_integer_raises_on_type_error(self): - cases = [b'', True] + cases = [b"", True] for case in cases: self.assertRaises(osc_types.ParseError, osc_types.get_int, case, 0) def test_get_integer_raises_on_wrong_start_index(self): self.assertRaises( - osc_types.ParseError, osc_types.get_int, b'\x00\x00\x00\x11', 1) + osc_types.ParseError, osc_types.get_int, b"\x00\x00\x00\x11", 1 + ) def test_get_integer_raises_on_wrong_start_index_negative(self): self.assertRaises( - osc_types.ParseError, osc_types.get_int, b'\x00\x00\x00\x00', -1) + osc_types.ParseError, osc_types.get_int, b"\x00\x00\x00\x00", -1 + ) def test_datagram_too_short(self): - dgram = b'\x00' * 3 + dgram = b"\x00" * 3 self.assertRaises(osc_types.ParseError, osc_types.get_int, dgram, 2) @@ -89,35 +85,34 @@ def test_get_rgba(self): b"\x00\x00\x00\x01": (1, 4), b"\x00\x00\x00\x02": (2, 4), b"\x00\x00\x00\x03": (3, 4), - b"\xFF\x00\x00\x00": (4278190080, 4), b"\x00\xFF\x00\x00": (16711680, 4), b"\x00\x00\xFF\x00": (65280, 4), b"\x00\x00\x00\xFF": (255, 4), - b"\x00\x00\x00\x01GARBAGE": (1, 4), } for dgram, expected in cases.items(): - self.assertEqual( - expected, osc_types.get_rgba(dgram, 0)) + self.assertEqual(expected, osc_types.get_rgba(dgram, 0)) def test_get_rgba_raises_on_type_error(self): - cases = [b'', True] + cases = [b"", True] for case in cases: self.assertRaises(osc_types.ParseError, osc_types.get_rgba, case, 0) def test_get_rgba_raises_on_wrong_start_index(self): self.assertRaises( - osc_types.ParseError, osc_types.get_rgba, b'\x00\x00\x00\x11', 1) + osc_types.ParseError, osc_types.get_rgba, b"\x00\x00\x00\x11", 1 + ) def test_get_rgba_raises_on_wrong_start_index_negative(self): self.assertRaises( - osc_types.ParseError, osc_types.get_rgba, b'\x00\x00\x00\x00', -1) + osc_types.ParseError, osc_types.get_rgba, b"\x00\x00\x00\x00", -1 + ) def test_datagram_too_short(self): - dgram = b'\x00' * 3 + dgram = b"\x00" * 3 self.assertRaises(osc_types.ParseError, osc_types.get_rgba, dgram, 2) @@ -128,43 +123,51 @@ def test_get_midi(self): b"\x00\x00\x00\x01": ((0, 0, 0, 1), 4), b"\x00\x00\x00\x02": ((0, 0, 0, 2), 4), b"\x00\x00\x00\x03": ((0, 0, 0, 3), 4), - b"\x00\x00\x01\x00": ((0, 0, 1, 0), 4), b"\x00\x01\x00\x00": ((0, 1, 0, 0), 4), b"\x01\x00\x00\x00": ((1, 0, 0, 0), 4), - b"\x00\x00\x00\x01GARBAGE": ((0, 0, 0, 1), 4), } for dgram, expected in cases.items(): - self.assertEqual( - expected, osc_types.get_midi(dgram, 0)) + self.assertEqual(expected, osc_types.get_midi(dgram, 0)) def test_get_midi_raises_on_type_error(self): - cases = [b'', True] + cases = [b"", True] for case in cases: self.assertRaises(osc_types.ParseError, osc_types.get_midi, case, 0) def test_get_midi_raises_on_wrong_start_index(self): self.assertRaises( - osc_types.ParseError, osc_types.get_midi, b'\x00\x00\x00\x11', 1) + osc_types.ParseError, osc_types.get_midi, b"\x00\x00\x00\x11", 1 + ) def test_get_midi_raises_on_wrong_start_index_negative(self): self.assertRaises( - osc_types.ParseError, osc_types.get_midi, b'\x00\x00\x00\x00', -1) + osc_types.ParseError, osc_types.get_midi, b"\x00\x00\x00\x00", -1 + ) def test_datagram_too_short(self): - dgram = b'\x00' * 3 + dgram = b"\x00" * 3 self.assertRaises(osc_types.ParseError, osc_types.get_midi, dgram, 2) class TestDate(unittest.TestCase): def test_get_timetag(self): cases = { - b"\xde\x9c\x91\xbf\x00\x01\x00\x00": ((datetime(2018, 5, 8, 21, 14, 39), 65536), 8), # NOTE: fraction is expresed as 32bit OSC. - b"\x00\x00\x00\x00\x00\x00\x00\x00": ((datetime(1900, 1, 1, 0, 0, 0), 0), 8), - b"\x83\xaa\x7E\x80\x0A\x00\xB0\x0C": ((datetime(1970, 1, 1, 0, 0, 0), 167817228), 8) + b"\xde\x9c\x91\xbf\x00\x01\x00\x00": ( + (datetime(2018, 5, 8, 21, 14, 39), 65536), + 8, + ), # NOTE: fraction is expresed as 32bit OSC. + b"\x00\x00\x00\x00\x00\x00\x00\x00": ( + (datetime(1900, 1, 1, 0, 0, 0), 0), + 8, + ), + b"\x83\xaa\x7E\x80\x0A\x00\xB0\x0C": ( + (datetime(1970, 1, 1, 0, 0, 0), 167817228), + 8, + ), } for dgram, expected in cases.items(): @@ -172,29 +175,37 @@ def test_get_timetag(self): def test_get_timetag_raises_on_wrong_start_index_negative(self): self.assertRaises( - osc_types.ParseError, osc_types.get_timetag, b'\x00\x00\x00\x00\x00\x00\x00\x00', -1) + osc_types.ParseError, + osc_types.get_timetag, + b"\x00\x00\x00\x00\x00\x00\x00\x00", + -1, + ) def test_get_timetag_raises_on_type_error(self): - cases = [b'', True] + cases = [b"", True] for case in cases: self.assertRaises(osc_types.ParseError, osc_types.get_timetag, case, 0) def test_get_timetag_raises_on_wrong_start_index(self): self.assertRaises( - osc_types.ParseError, osc_types.get_date, b'\x00\x00\x00\x11\x00\x00\x00\x11', 1) + osc_types.ParseError, + osc_types.get_date, + b"\x00\x00\x00\x11\x00\x00\x00\x11", + 1, + ) def test_ttag_datagram_too_short(self): - dgram = b'\x00' * 7 + dgram = b"\x00" * 7 self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 6) - dgram = b'\x00' * 2 + dgram = b"\x00" * 2 self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 1) - dgram = b'\x00' * 5 + dgram = b"\x00" * 5 self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 4) - dgram = b'\x00' * 1 + dgram = b"\x00" * 1 self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 0) @@ -203,8 +214,7 @@ def test_get_float(self): cases = { b"\x00\x00\x00\x00": (0.0, 4), b"?\x80\x00\x00'": (1.0, 4), - b'@\x00\x00\x00': (2.0, 4), - + b"@\x00\x00\x00": (2.0, 4), b"\x00\x00\x00\x00GARBAGE": (0.0, 4), } @@ -224,19 +234,18 @@ def test_get_float_raises_on_type_error(self): self.assertRaises(osc_types.ParseError, osc_types.get_float, case, 0) def test_datagram_too_short_pads(self): - dgram = b'\x00' * 2 + dgram = b"\x00" * 2 self.assertEqual((0, 4), osc_types.get_float(dgram, 0)) class TestDouble(unittest.TestCase): def test_get_double(self): cases = { - b'\x00\x00\x00\x00\x00\x00\x00\x00': (0.0, 8), - b'?\xf0\x00\x00\x00\x00\x00\x00': (1.0, 8), - b'@\x00\x00\x00\x00\x00\x00\x00': (2.0, 8), - b'\xbf\xf0\x00\x00\x00\x00\x00\x00': (-1.0, 8), - b'\xc0\x00\x00\x00\x00\x00\x00\x00': (-2.0, 8), - + b"\x00\x00\x00\x00\x00\x00\x00\x00": (0.0, 8), + b"?\xf0\x00\x00\x00\x00\x00\x00": (1.0, 8), + b"@\x00\x00\x00\x00\x00\x00\x00": (2.0, 8), + b"\xbf\xf0\x00\x00\x00\x00\x00\x00": (-1.0, 8), + b"\xc0\x00\x00\x00\x00\x00\x00\x00": (-2.0, 8), b"\x00\x00\x00\x00\x00\x00\x00\x00GARBAGE": (0.0, 8), } @@ -256,7 +265,7 @@ def test_get_double_raises_on_type_error(self): self.assertRaises(osc_types.ParseError, osc_types.get_double, case, 0) def test_datagram_too_short_pads(self): - dgram = b'\x00' * 2 + dgram = b"\x00" * 2 self.assertRaises(osc_types.ParseError, osc_types.get_double, dgram, 0) @@ -267,33 +276,35 @@ def test_get_blob(self): b"\x00\x00\x00\x08stuff\x00\x00\x00": (b"stuff\x00\x00\x00", 12), b"\x00\x00\x00\x04\x00\x00\x00\x00": (b"\x00\x00\x00\x00", 8), b"\x00\x00\x00\x02\x00\x00\x00\x00": (b"\x00\x00", 8), - b"\x00\x00\x00\x08stuff\x00\x00\x00datagramcontinues": ( - b"stuff\x00\x00\x00", 12), + b"stuff\x00\x00\x00", + 12, + ), } for dgram, expected in cases.items(): self.assertEqual(expected, osc_types.get_blob(dgram, 0)) def test_get_blob_raises_on_wrong_dgram(self): - cases = [b'', True, b"\x00\x00\x00\x08"] + cases = [b"", True, b"\x00\x00\x00\x08"] for case in cases: self.assertRaises(osc_types.ParseError, osc_types.get_blob, case, 0) def test_get_blob_raises_on_wrong_start_index(self): self.assertRaises( - osc_types.ParseError, osc_types.get_blob, b'\x00\x00\x00\x11', 1) + osc_types.ParseError, osc_types.get_blob, b"\x00\x00\x00\x11", 1 + ) def test_get_blob_raises_too_short_buffer(self): self.assertRaises( - osc_types.ParseError, - osc_types.get_blob, - b'\x00\x00\x00\x11\x00\x00', 1) + osc_types.ParseError, osc_types.get_blob, b"\x00\x00\x00\x11\x00\x00", 1 + ) def test_get_blog_raises_on_wrong_start_index_negative(self): self.assertRaises( - osc_types.ParseError, osc_types.get_blob, b'\x00\x00\x00\x00', -1) + osc_types.ParseError, osc_types.get_blob, b"\x00\x00\x00\x00", -1 + ) class TestNTPTimestamp(unittest.TestCase): @@ -302,56 +313,57 @@ def test_immediately_dgram(self): self.assertEqual(osc_types.IMMEDIATELY, osc_types.get_date(dgram, 0)[0]) def test_origin_of_time(self): - dgram = b'\x00' * 8 + dgram = b"\x00" * 8 self.assertGreater(0, osc_types.get_date(dgram, 0)[0]) def test_datagram_too_short(self): - dgram = b'\x00' * 8 + dgram = b"\x00" * 8 self.assertRaises(osc_types.ParseError, osc_types.get_date, dgram, 2) def test_write_date(self): time = 1569899476.167749 # known round(time.time(), 6) - timetag = b'\xe1=BT*\xf1\x98\x00' + timetag = b"\xe1=BT*\xf1\x98\x00" self.assertEqual(timetag, osc_types.write_date(time)) class TestBuildMethods(unittest.TestCase): def test_string(self): - self.assertEqual(b'\x00\x00\x00\x00', osc_types.write_string('')) - self.assertEqual(b'A\x00\x00\x00', osc_types.write_string('A')) - self.assertEqual(b'AB\x00\x00', osc_types.write_string('AB')) - self.assertEqual(b'ABC\x00', osc_types.write_string('ABC')) - self.assertEqual(b'ABCD\x00\x00\x00\x00', osc_types.write_string('ABCD')) + self.assertEqual(b"\x00\x00\x00\x00", osc_types.write_string("")) + self.assertEqual(b"A\x00\x00\x00", osc_types.write_string("A")) + self.assertEqual(b"AB\x00\x00", osc_types.write_string("AB")) + self.assertEqual(b"ABC\x00", osc_types.write_string("ABC")) + self.assertEqual(b"ABCD\x00\x00\x00\x00", osc_types.write_string("ABCD")) def test_string_raises(self): self.assertRaises(osc_types.BuildError, osc_types.write_string, 123) def test_int(self): - self.assertEqual(b'\x00\x00\x00\x00', osc_types.write_int(0)) - self.assertEqual(b'\x00\x00\x00\x01', osc_types.write_int(1)) + self.assertEqual(b"\x00\x00\x00\x00", osc_types.write_int(0)) + self.assertEqual(b"\x00\x00\x00\x01", osc_types.write_int(1)) def test_int_raises(self): - self.assertRaises(osc_types.BuildError, osc_types.write_int, 'no int') + self.assertRaises(osc_types.BuildError, osc_types.write_int, "no int") def test_float(self): - self.assertEqual(b'\x00\x00\x00\x00', osc_types.write_float(0.0)) - self.assertEqual(b'?\x00\x00\x00', osc_types.write_float(0.5)) - self.assertEqual(b'?\x80\x00\x00', osc_types.write_float(1.0)) - self.assertEqual(b'?\x80\x00\x00', osc_types.write_float(1)) + self.assertEqual(b"\x00\x00\x00\x00", osc_types.write_float(0.0)) + self.assertEqual(b"?\x00\x00\x00", osc_types.write_float(0.5)) + self.assertEqual(b"?\x80\x00\x00", osc_types.write_float(1.0)) + self.assertEqual(b"?\x80\x00\x00", osc_types.write_float(1)) def test_float_raises(self): - self.assertRaises(osc_types.BuildError, osc_types.write_float, 'no float') + self.assertRaises(osc_types.BuildError, osc_types.write_float, "no float") def test_blob(self): self.assertEqual( - b'\x00\x00\x00\x02\x00\x01\x00\x00', - osc_types.write_blob(b'\x00\x01')) + b"\x00\x00\x00\x02\x00\x01\x00\x00", osc_types.write_blob(b"\x00\x01") + ) self.assertEqual( - b'\x00\x00\x00\x04\x00\x01\x02\x03', - osc_types.write_blob(b'\x00\x01\x02\x03')) + b"\x00\x00\x00\x04\x00\x01\x02\x03", + osc_types.write_blob(b"\x00\x01\x02\x03"), + ) def test_blob_raises(self): - self.assertRaises(osc_types.BuildError, osc_types.write_blob, b'') + self.assertRaises(osc_types.BuildError, osc_types.write_blob, b"") if __name__ == "__main__": diff --git a/pythonosc/test/test_dispatcher.py b/pythonosc/test/test_dispatcher.py index 5d9d2fd..523facd 100644 --- a/pythonosc/test/test_dispatcher.py +++ b/pythonosc/test/test_dispatcher.py @@ -15,22 +15,28 @@ def sort(lst): return self.assertSequenceEqual(sort(expected), sort(result)) def test_empty_by_default(self): - self.sortAndAssertSequenceEqual([], self.dispatcher.handlers_for_address('/test')) + self.sortAndAssertSequenceEqual( + [], self.dispatcher.handlers_for_address("/test") + ) def test_use_default_handler_when_set_and_no_match(self): handler = object() self.dispatcher.set_default_handler(handler) - self.sortAndAssertSequenceEqual([Handler(handler, [])], self.dispatcher.handlers_for_address('/test')) + self.sortAndAssertSequenceEqual( + [Handler(handler, [])], self.dispatcher.handlers_for_address("/test") + ) def test_simple_map_and_match(self): handler = object() - self.dispatcher.map('/test', handler, 1, 2, 3) - self.dispatcher.map('/test2', handler) + self.dispatcher.map("/test", handler, 1, 2, 3) + self.dispatcher.map("/test2", handler) self.sortAndAssertSequenceEqual( - [Handler(handler, [1, 2, 3])], self.dispatcher.handlers_for_address('/test')) + [Handler(handler, [1, 2, 3])], self.dispatcher.handlers_for_address("/test") + ) self.sortAndAssertSequenceEqual( - [Handler(handler, [])], self.dispatcher.handlers_for_address('/test2')) + [Handler(handler, [])], self.dispatcher.handlers_for_address("/test2") + ) def test_example_from_spec(self): addresses = [ @@ -46,80 +52,98 @@ def test_example_from_spec(self): for index, address in enumerate(addresses): self.sortAndAssertSequenceEqual( - [Handler(index, [])], self.dispatcher.handlers_for_address(address)) + [Handler(index, [])], self.dispatcher.handlers_for_address(address) + ) self.sortAndAssertSequenceEqual( - [Handler(1, []), Handler(2, [])], self.dispatcher.handlers_for_address("/second/?")) + [Handler(1, []), Handler(2, [])], + self.dispatcher.handlers_for_address("/second/?"), + ) self.sortAndAssertSequenceEqual( [Handler(3, []), Handler(4, []), Handler(5, [])], - self.dispatcher.handlers_for_address("/third/*")) + self.dispatcher.handlers_for_address("/third/*"), + ) def test_do_not_match_over_slash(self): - self.dispatcher.map('/foo/bar/1', 1) - self.dispatcher.map('/foo/bar/2', 2) + self.dispatcher.map("/foo/bar/1", 1) + self.dispatcher.map("/foo/bar/2", 2) - self.sortAndAssertSequenceEqual( - [], self.dispatcher.handlers_for_address("/*")) + self.sortAndAssertSequenceEqual([], self.dispatcher.handlers_for_address("/*")) def test_match_middle_star(self): - self.dispatcher.map('/foo/bar/1', 1) - self.dispatcher.map('/foo/bar/2', 2) + self.dispatcher.map("/foo/bar/1", 1) + self.dispatcher.map("/foo/bar/2", 2) self.sortAndAssertSequenceEqual( - [Handler(2, [])], self.dispatcher.handlers_for_address("/foo/*/2")) + [Handler(2, [])], self.dispatcher.handlers_for_address("/foo/*/2") + ) def test_match_multiple_stars(self): - self.dispatcher.map('/foo/bar/1', 1) - self.dispatcher.map('/foo/bar/2', 2) + self.dispatcher.map("/foo/bar/1", 1) + self.dispatcher.map("/foo/bar/2", 2) self.sortAndAssertSequenceEqual( - [Handler(1, []), Handler(2, [])], self.dispatcher.handlers_for_address("/*/*/*")) + [Handler(1, []), Handler(2, [])], + self.dispatcher.handlers_for_address("/*/*/*"), + ) def test_match_address_contains_plus_as_character(self): - self.dispatcher.map('/footest/bar+tender/1', 1) + self.dispatcher.map("/footest/bar+tender/1", 1) self.sortAndAssertSequenceEqual( - [Handler(1, [])], self.dispatcher.handlers_for_address("/foo*/bar+*/*")) + [Handler(1, [])], self.dispatcher.handlers_for_address("/foo*/bar+*/*") + ) self.sortAndAssertSequenceEqual( - [Handler(1, [])], self.dispatcher.handlers_for_address("/foo*/bar*/*")) + [Handler(1, [])], self.dispatcher.handlers_for_address("/foo*/bar*/*") + ) def test_call_correct_dispatcher_on_star(self): - self.dispatcher.map('/a+b', 1) - self.dispatcher.map('/aaab', 2) + self.dispatcher.map("/a+b", 1) + self.dispatcher.map("/aaab", 2) self.sortAndAssertSequenceEqual( - [Handler(2, [])], self.dispatcher.handlers_for_address('/aaab')) + [Handler(2, [])], self.dispatcher.handlers_for_address("/aaab") + ) self.sortAndAssertSequenceEqual( - [Handler(1, [])], self.dispatcher.handlers_for_address('/a+b')) + [Handler(1, [])], self.dispatcher.handlers_for_address("/a+b") + ) def test_map_star(self): - self.dispatcher.map('/starbase/*', 1) + self.dispatcher.map("/starbase/*", 1) self.sortAndAssertSequenceEqual( - [Handler(1, [])], self.dispatcher.handlers_for_address("/starbase/bar")) + [Handler(1, [])], self.dispatcher.handlers_for_address("/starbase/bar") + ) def test_map_root_star(self): - self.dispatcher.map('/*', 1) + self.dispatcher.map("/*", 1) self.sortAndAssertSequenceEqual( - [Handler(1, [])], self.dispatcher.handlers_for_address("/anything/matches")) + [Handler(1, [])], self.dispatcher.handlers_for_address("/anything/matches") + ) def test_map_double_stars(self): - self.dispatcher.map('/foo/*/bar/*', 1) + self.dispatcher.map("/foo/*/bar/*", 1) self.sortAndAssertSequenceEqual( - [Handler(1, [])], self.dispatcher.handlers_for_address("/foo/wild/bar/wild")) + [Handler(1, [])], self.dispatcher.handlers_for_address("/foo/wild/bar/wild") + ) self.sortAndAssertSequenceEqual( - [], self.dispatcher.handlers_for_address("/foo/wild/nomatch/wild")) + [], self.dispatcher.handlers_for_address("/foo/wild/nomatch/wild") + ) def test_multiple_handlers(self): - self.dispatcher.map('/foo/bar', 1) - self.dispatcher.map('/foo/bar', 2) + self.dispatcher.map("/foo/bar", 1) + self.dispatcher.map("/foo/bar", 2) self.sortAndAssertSequenceEqual( - [Handler(1, []), Handler(2, [])], self.dispatcher.handlers_for_address("/foo/bar")) + [Handler(1, []), Handler(2, [])], + self.dispatcher.handlers_for_address("/foo/bar"), + ) def test_multiple_handlers_with_wildcard_map(self): - self.dispatcher.map('/foo/bar', 1) - self.dispatcher.map('/*', 2) + self.dispatcher.map("/foo/bar", 1) + self.dispatcher.map("/*", 2) self.sortAndAssertSequenceEqual( - [Handler(1, []), Handler(2, [])], self.dispatcher.handlers_for_address("/foo/bar")) + [Handler(1, []), Handler(2, [])], + self.dispatcher.handlers_for_address("/foo/bar"), + ) def test_unmap(self): def dummyhandler(): @@ -127,16 +151,24 @@ def dummyhandler(): # Test with handler returned by map returnedhandler = self.dispatcher.map("/map/me", dummyhandler) - self.sortAndAssertSequenceEqual([Handler(dummyhandler, [])], self.dispatcher.handlers_for_address("/map/me")) + self.sortAndAssertSequenceEqual( + [Handler(dummyhandler, [])], self.dispatcher.handlers_for_address("/map/me") + ) self.dispatcher.unmap("/map/me", returnedhandler) - self.sortAndAssertSequenceEqual([], self.dispatcher.handlers_for_address("/map/me")) + self.sortAndAssertSequenceEqual( + [], self.dispatcher.handlers_for_address("/map/me") + ) # Test with reconstructing handler self.dispatcher.map("/map/me/too", dummyhandler) - self.sortAndAssertSequenceEqual([Handler(dummyhandler, [])], - self.dispatcher.handlers_for_address("/map/me/too")) + self.sortAndAssertSequenceEqual( + [Handler(dummyhandler, [])], + self.dispatcher.handlers_for_address("/map/me/too"), + ) self.dispatcher.unmap("/map/me/too", dummyhandler) - self.sortAndAssertSequenceEqual([], self.dispatcher.handlers_for_address("/map/me/too")) + self.sortAndAssertSequenceEqual( + [], self.dispatcher.handlers_for_address("/map/me/too") + ) def test_unmap_exception(self): def dummyhandler(): diff --git a/pythonosc/test/test_osc_bundle.py b/pythonosc/test/test_osc_bundle.py index f118cdd..7e7fc54 100644 --- a/pythonosc/test/test_osc_bundle.py +++ b/pythonosc/test/test_osc_bundle.py @@ -10,7 +10,8 @@ b"\x00\x00\x00\x14" b"/LFO_Rate\x00\x00\x00" b",f\x00\x00" - b">\x8c\xcc\xcd") + b">\x8c\xcc\xcd" +) _DGRAM_SWITCH_GOES_OFF = ( b"#bundle\x00" @@ -18,7 +19,8 @@ b"\x00\x00\x00\x10" b"/SYNC\x00\x00\x00" b",f\x00\x00" - b"\x00\x00\x00\x00") + b"\x00\x00\x00\x00" +) _DGRAM_SWITCH_GOES_ON = ( b"#bundle\x00" @@ -26,7 +28,8 @@ b"\x00\x00\x00\x10" b"/SYNC\x00\x00\x00" b",f\x00\x00" - b"?\x00\x00\x00") + b"?\x00\x00\x00" +) _DGRAM_TWO_MESSAGES_IN_BUNDLE = ( b"#bundle\x00" @@ -40,11 +43,10 @@ b"\x00\x00\x00\x10" b"/SYNC\x00\x00\x00" b",f\x00\x00" - b"?\x00\x00\x00") + b"?\x00\x00\x00" +) -_DGRAM_EMPTY_BUNDLE = ( - b"#bundle\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x01") +_DGRAM_EMPTY_BUNDLE = b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" _DGRAM_BUNDLE_IN_BUNDLE = ( b"#bundle\x00" @@ -55,23 +57,24 @@ b"\x00\x00\x00\x10" b"/SYNC\x00\x00\x00" b",f\x00\x00" - b"?\x00\x00\x00") + b"?\x00\x00\x00" +) -_DGRAM_INVALID = ( - b"#bundle\x00" - b"\x00\x00\x00") +_DGRAM_INVALID = b"#bundle\x00" b"\x00\x00\x00" _DGRAM_INVALID_INDEX = ( b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" b"\x00\x00\x00\x20" - b"/SYNC\x00\x00\x00\x00") + b"/SYNC\x00\x00\x00\x00" +) _DGRAM_UNKNOWN_TYPE = ( b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" b"\x00\x00\x00\x10" - b"iamnotaslash") + b"iamnotaslash" +) class TestOscBundle(unittest.TestCase): @@ -112,15 +115,14 @@ def test_bundle_in_bundle_we_must_go_deeper(self): self.assertEqual(osc_bundle.OscBundle, type(bundle.content(0))) def test_dgram_is_bundle(self): - self.assertTrue(osc_bundle.OscBundle.dgram_is_bundle( - _DGRAM_SWITCH_GOES_ON)) - self.assertFalse(osc_bundle.OscBundle.dgram_is_bundle(b'junk')) + self.assertTrue(osc_bundle.OscBundle.dgram_is_bundle(_DGRAM_SWITCH_GOES_ON)) + self.assertFalse(osc_bundle.OscBundle.dgram_is_bundle(b"junk")) def test_raises_on_invalid_datagram(self): + self.assertRaises(osc_bundle.ParseError, osc_bundle.OscBundle, _DGRAM_INVALID) self.assertRaises( - osc_bundle.ParseError, osc_bundle.OscBundle, _DGRAM_INVALID) - self.assertRaises( - osc_bundle.ParseError, osc_bundle.OscBundle, _DGRAM_INVALID_INDEX) + osc_bundle.ParseError, osc_bundle.OscBundle, _DGRAM_INVALID_INDEX + ) def test_unknown_type(self): osc_bundle.OscBundle(_DGRAM_UNKNOWN_TYPE) diff --git a/pythonosc/test/test_osc_bundle_builder.py b/pythonosc/test/test_osc_bundle_builder.py index ee5479b..1d20f43 100644 --- a/pythonosc/test/test_osc_bundle_builder.py +++ b/pythonosc/test/test_osc_bundle_builder.py @@ -7,7 +7,8 @@ class TestOscBundleBuilder(unittest.TestCase): def test_empty_bundle(self): bundle = osc_bundle_builder.OscBundleBuilder( - osc_bundle_builder.IMMEDIATELY).build() + osc_bundle_builder.IMMEDIATELY + ).build() self.assertEqual(0, bundle.num_contents) def test_raises_on_build(self): @@ -20,8 +21,7 @@ def test_raises_on_invalid_timestamp(self): self.assertRaises(osc_bundle_builder.BuildError, bundle.build) def test_build_complex_bundle(self): - bundle = osc_bundle_builder.OscBundleBuilder( - osc_bundle_builder.IMMEDIATELY) + bundle = osc_bundle_builder.OscBundleBuilder(osc_bundle_builder.IMMEDIATELY) msg = osc_message_builder.OscMessageBuilder(address="/SYNC") msg.add_arg(4.0) # Add 4 messages in the bundle, each with more arguments. diff --git a/pythonosc/test/test_osc_message.py b/pythonosc/test/test_osc_message.py index efe0076..269901b 100644 --- a/pythonosc/test/test_osc_message.py +++ b/pythonosc/test/test_osc_message.py @@ -5,20 +5,11 @@ from datetime import datetime # Datagrams sent by Reaktor 5.8 by Native Instruments (c). -_DGRAM_KNOB_ROTATES = ( - b"/FB\x00" - b",f\x00\x00" - b">xca=q") +_DGRAM_KNOB_ROTATES = b"/FB\x00" b",f\x00\x00" b">xca=q" -_DGRAM_SWITCH_GOES_OFF = ( - b"/SYNC\x00\x00\x00" - b",f\x00\x00" - b"\x00\x00\x00\x00") +_DGRAM_SWITCH_GOES_OFF = b"/SYNC\x00\x00\x00" b",f\x00\x00" b"\x00\x00\x00\x00" -_DGRAM_SWITCH_GOES_ON = ( - b"/SYNC\x00\x00\x00" - b",f\x00\x00" - b"?\x00\x00\x00") +_DGRAM_SWITCH_GOES_ON = b"/SYNC\x00\x00\x00" b",f\x00\x00" b"?\x00\x00\x00" _DGRAM_NO_PARAMS = b"/SYNC\x00\x00\x00" @@ -28,7 +19,8 @@ b"\x00\x00\x00\x03" # 3 b"@\x00\x00\x00" # 2.0 b"ABC\x00" # "ABC" - b"\x00\x00\x00\x08stuff\x00\x00\x00") # b"stuff\x00\x00\x00" + b"\x00\x00\x00\x08stuff\x00\x00\x00" +) # b"stuff\x00\x00\x00" _DGRAM_ALL_NON_STANDARD_TYPES_OF_PARAMS = ( b"/SYNC\x00\x00\x00" @@ -48,17 +40,17 @@ b"DEF\x00" # "DEF" b"\x00\x00\x00\x02" # 2 b"\x00\x00\x00\x03" # 3 - b"GHI\x00") # "GHI" + b"GHI\x00" +) # "GHI" _DGRAM_UNKNOWN_PARAM_TYPE = ( b"/SYNC\x00\x00\x00" b",fx\x00" # x is an unknown param type. - b"?\x00\x00\x00") + b"?\x00\x00\x00" +) # range(512) param list. -_DGRAM_LONG_LIST = ( - b'/SYNC\x00\x00\x00,[iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii]\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x04\x00\x00\x00\x05\x00\x00\x00\x06\x00\x00\x00\x07\x00\x00\x00\x08\x00\x00\x00\t\x00\x00\x00\n\x00\x00\x00\x0b\x00\x00\x00\x0c\x00\x00\x00\r\x00\x00\x00\x0e\x00\x00\x00\x0f\x00\x00\x00\x10\x00\x00\x00\x11\x00\x00\x00\x12\x00\x00\x00\x13\x00\x00\x00\x14\x00\x00\x00\x15\x00\x00\x00\x16\x00\x00\x00\x17\x00\x00\x00\x18\x00\x00\x00\x19\x00\x00\x00\x1a\x00\x00\x00\x1b\x00\x00\x00\x1c\x00\x00\x00\x1d\x00\x00\x00\x1e\x00\x00\x00\x1f\x00\x00\x00 \x00\x00\x00!\x00\x00\x00"\x00\x00\x00#\x00\x00\x00$\x00\x00\x00%\x00\x00\x00&\x00\x00\x00\'\x00\x00\x00(\x00\x00\x00)\x00\x00\x00*\x00\x00\x00+\x00\x00\x00,\x00\x00\x00-\x00\x00\x00.\x00\x00\x00/\x00\x00\x000\x00\x00\x001\x00\x00\x002\x00\x00\x003\x00\x00\x004\x00\x00\x005\x00\x00\x006\x00\x00\x007\x00\x00\x008\x00\x00\x009\x00\x00\x00:\x00\x00\x00;\x00\x00\x00<\x00\x00\x00=\x00\x00\x00>\x00\x00\x00?\x00\x00\x00@\x00\x00\x00A\x00\x00\x00B\x00\x00\x00C\x00\x00\x00D\x00\x00\x00E\x00\x00\x00F\x00\x00\x00G\x00\x00\x00H\x00\x00\x00I\x00\x00\x00J\x00\x00\x00K\x00\x00\x00L\x00\x00\x00M\x00\x00\x00N\x00\x00\x00O\x00\x00\x00P\x00\x00\x00Q\x00\x00\x00R\x00\x00\x00S\x00\x00\x00T\x00\x00\x00U\x00\x00\x00V\x00\x00\x00W\x00\x00\x00X\x00\x00\x00Y\x00\x00\x00Z\x00\x00\x00[\x00\x00\x00\\\x00\x00\x00]\x00\x00\x00^\x00\x00\x00_\x00\x00\x00`\x00\x00\x00a\x00\x00\x00b\x00\x00\x00c\x00\x00\x00d\x00\x00\x00e\x00\x00\x00f\x00\x00\x00g\x00\x00\x00h\x00\x00\x00i\x00\x00\x00j\x00\x00\x00k\x00\x00\x00l\x00\x00\x00m\x00\x00\x00n\x00\x00\x00o\x00\x00\x00p\x00\x00\x00q\x00\x00\x00r\x00\x00\x00s\x00\x00\x00t\x00\x00\x00u\x00\x00\x00v\x00\x00\x00w\x00\x00\x00x\x00\x00\x00y\x00\x00\x00z\x00\x00\x00{\x00\x00\x00|\x00\x00\x00}\x00\x00\x00~\x00\x00\x00\x7f\x00\x00\x00\x80\x00\x00\x00\x81\x00\x00\x00\x82\x00\x00\x00\x83\x00\x00\x00\x84\x00\x00\x00\x85\x00\x00\x00\x86\x00\x00\x00\x87\x00\x00\x00\x88\x00\x00\x00\x89\x00\x00\x00\x8a\x00\x00\x00\x8b\x00\x00\x00\x8c\x00\x00\x00\x8d\x00\x00\x00\x8e\x00\x00\x00\x8f\x00\x00\x00\x90\x00\x00\x00\x91\x00\x00\x00\x92\x00\x00\x00\x93\x00\x00\x00\x94\x00\x00\x00\x95\x00\x00\x00\x96\x00\x00\x00\x97\x00\x00\x00\x98\x00\x00\x00\x99\x00\x00\x00\x9a\x00\x00\x00\x9b\x00\x00\x00\x9c\x00\x00\x00\x9d\x00\x00\x00\x9e\x00\x00\x00\x9f\x00\x00\x00\xa0\x00\x00\x00\xa1\x00\x00\x00\xa2\x00\x00\x00\xa3\x00\x00\x00\xa4\x00\x00\x00\xa5\x00\x00\x00\xa6\x00\x00\x00\xa7\x00\x00\x00\xa8\x00\x00\x00\xa9\x00\x00\x00\xaa\x00\x00\x00\xab\x00\x00\x00\xac\x00\x00\x00\xad\x00\x00\x00\xae\x00\x00\x00\xaf\x00\x00\x00\xb0\x00\x00\x00\xb1\x00\x00\x00\xb2\x00\x00\x00\xb3\x00\x00\x00\xb4\x00\x00\x00\xb5\x00\x00\x00\xb6\x00\x00\x00\xb7\x00\x00\x00\xb8\x00\x00\x00\xb9\x00\x00\x00\xba\x00\x00\x00\xbb\x00\x00\x00\xbc\x00\x00\x00\xbd\x00\x00\x00\xbe\x00\x00\x00\xbf\x00\x00\x00\xc0\x00\x00\x00\xc1\x00\x00\x00\xc2\x00\x00\x00\xc3\x00\x00\x00\xc4\x00\x00\x00\xc5\x00\x00\x00\xc6\x00\x00\x00\xc7\x00\x00\x00\xc8\x00\x00\x00\xc9\x00\x00\x00\xca\x00\x00\x00\xcb\x00\x00\x00\xcc\x00\x00\x00\xcd\x00\x00\x00\xce\x00\x00\x00\xcf\x00\x00\x00\xd0\x00\x00\x00\xd1\x00\x00\x00\xd2\x00\x00\x00\xd3\x00\x00\x00\xd4\x00\x00\x00\xd5\x00\x00\x00\xd6\x00\x00\x00\xd7\x00\x00\x00\xd8\x00\x00\x00\xd9\x00\x00\x00\xda\x00\x00\x00\xdb\x00\x00\x00\xdc\x00\x00\x00\xdd\x00\x00\x00\xde\x00\x00\x00\xdf\x00\x00\x00\xe0\x00\x00\x00\xe1\x00\x00\x00\xe2\x00\x00\x00\xe3\x00\x00\x00\xe4\x00\x00\x00\xe5\x00\x00\x00\xe6\x00\x00\x00\xe7\x00\x00\x00\xe8\x00\x00\x00\xe9\x00\x00\x00\xea\x00\x00\x00\xeb\x00\x00\x00\xec\x00\x00\x00\xed\x00\x00\x00\xee\x00\x00\x00\xef\x00\x00\x00\xf0\x00\x00\x00\xf1\x00\x00\x00\xf2\x00\x00\x00\xf3\x00\x00\x00\xf4\x00\x00\x00\xf5\x00\x00\x00\xf6\x00\x00\x00\xf7\x00\x00\x00\xf8\x00\x00\x00\xf9\x00\x00\x00\xfa\x00\x00\x00\xfb\x00\x00\x00\xfc\x00\x00\x00\xfd\x00\x00\x00\xfe\x00\x00\x00\xff\x00\x00\x01\x00\x00\x00\x01\x01\x00\x00\x01\x02\x00\x00\x01\x03\x00\x00\x01\x04\x00\x00\x01\x05\x00\x00\x01\x06\x00\x00\x01\x07\x00\x00\x01\x08\x00\x00\x01\t\x00\x00\x01\n\x00\x00\x01\x0b\x00\x00\x01\x0c\x00\x00\x01\r\x00\x00\x01\x0e\x00\x00\x01\x0f\x00\x00\x01\x10\x00\x00\x01\x11\x00\x00\x01\x12\x00\x00\x01\x13\x00\x00\x01\x14\x00\x00\x01\x15\x00\x00\x01\x16\x00\x00\x01\x17\x00\x00\x01\x18\x00\x00\x01\x19\x00\x00\x01\x1a\x00\x00\x01\x1b\x00\x00\x01\x1c\x00\x00\x01\x1d\x00\x00\x01\x1e\x00\x00\x01\x1f\x00\x00\x01 \x00\x00\x01!\x00\x00\x01"\x00\x00\x01#\x00\x00\x01$\x00\x00\x01%\x00\x00\x01&\x00\x00\x01\'\x00\x00\x01(\x00\x00\x01)\x00\x00\x01*\x00\x00\x01+\x00\x00\x01,\x00\x00\x01-\x00\x00\x01.\x00\x00\x01/\x00\x00\x010\x00\x00\x011\x00\x00\x012\x00\x00\x013\x00\x00\x014\x00\x00\x015\x00\x00\x016\x00\x00\x017\x00\x00\x018\x00\x00\x019\x00\x00\x01:\x00\x00\x01;\x00\x00\x01<\x00\x00\x01=\x00\x00\x01>\x00\x00\x01?\x00\x00\x01@\x00\x00\x01A\x00\x00\x01B\x00\x00\x01C\x00\x00\x01D\x00\x00\x01E\x00\x00\x01F\x00\x00\x01G\x00\x00\x01H\x00\x00\x01I\x00\x00\x01J\x00\x00\x01K\x00\x00\x01L\x00\x00\x01M\x00\x00\x01N\x00\x00\x01O\x00\x00\x01P\x00\x00\x01Q\x00\x00\x01R\x00\x00\x01S\x00\x00\x01T\x00\x00\x01U\x00\x00\x01V\x00\x00\x01W\x00\x00\x01X\x00\x00\x01Y\x00\x00\x01Z\x00\x00\x01[\x00\x00\x01\\\x00\x00\x01]\x00\x00\x01^\x00\x00\x01_\x00\x00\x01`\x00\x00\x01a\x00\x00\x01b\x00\x00\x01c\x00\x00\x01d\x00\x00\x01e\x00\x00\x01f\x00\x00\x01g\x00\x00\x01h\x00\x00\x01i\x00\x00\x01j\x00\x00\x01k\x00\x00\x01l\x00\x00\x01m\x00\x00\x01n\x00\x00\x01o\x00\x00\x01p\x00\x00\x01q\x00\x00\x01r\x00\x00\x01s\x00\x00\x01t\x00\x00\x01u\x00\x00\x01v\x00\x00\x01w\x00\x00\x01x\x00\x00\x01y\x00\x00\x01z\x00\x00\x01{\x00\x00\x01|\x00\x00\x01}\x00\x00\x01~\x00\x00\x01\x7f\x00\x00\x01\x80\x00\x00\x01\x81\x00\x00\x01\x82\x00\x00\x01\x83\x00\x00\x01\x84\x00\x00\x01\x85\x00\x00\x01\x86\x00\x00\x01\x87\x00\x00\x01\x88\x00\x00\x01\x89\x00\x00\x01\x8a\x00\x00\x01\x8b\x00\x00\x01\x8c\x00\x00\x01\x8d\x00\x00\x01\x8e\x00\x00\x01\x8f\x00\x00\x01\x90\x00\x00\x01\x91\x00\x00\x01\x92\x00\x00\x01\x93\x00\x00\x01\x94\x00\x00\x01\x95\x00\x00\x01\x96\x00\x00\x01\x97\x00\x00\x01\x98\x00\x00\x01\x99\x00\x00\x01\x9a\x00\x00\x01\x9b\x00\x00\x01\x9c\x00\x00\x01\x9d\x00\x00\x01\x9e\x00\x00\x01\x9f\x00\x00\x01\xa0\x00\x00\x01\xa1\x00\x00\x01\xa2\x00\x00\x01\xa3\x00\x00\x01\xa4\x00\x00\x01\xa5\x00\x00\x01\xa6\x00\x00\x01\xa7\x00\x00\x01\xa8\x00\x00\x01\xa9\x00\x00\x01\xaa\x00\x00\x01\xab\x00\x00\x01\xac\x00\x00\x01\xad\x00\x00\x01\xae\x00\x00\x01\xaf\x00\x00\x01\xb0\x00\x00\x01\xb1\x00\x00\x01\xb2\x00\x00\x01\xb3\x00\x00\x01\xb4\x00\x00\x01\xb5\x00\x00\x01\xb6\x00\x00\x01\xb7\x00\x00\x01\xb8\x00\x00\x01\xb9\x00\x00\x01\xba\x00\x00\x01\xbb\x00\x00\x01\xbc\x00\x00\x01\xbd\x00\x00\x01\xbe\x00\x00\x01\xbf\x00\x00\x01\xc0\x00\x00\x01\xc1\x00\x00\x01\xc2\x00\x00\x01\xc3\x00\x00\x01\xc4\x00\x00\x01\xc5\x00\x00\x01\xc6\x00\x00\x01\xc7\x00\x00\x01\xc8\x00\x00\x01\xc9\x00\x00\x01\xca\x00\x00\x01\xcb\x00\x00\x01\xcc\x00\x00\x01\xcd\x00\x00\x01\xce\x00\x00\x01\xcf\x00\x00\x01\xd0\x00\x00\x01\xd1\x00\x00\x01\xd2\x00\x00\x01\xd3\x00\x00\x01\xd4\x00\x00\x01\xd5\x00\x00\x01\xd6\x00\x00\x01\xd7\x00\x00\x01\xd8\x00\x00\x01\xd9\x00\x00\x01\xda\x00\x00\x01\xdb\x00\x00\x01\xdc\x00\x00\x01\xdd\x00\x00\x01\xde\x00\x00\x01\xdf\x00\x00\x01\xe0\x00\x00\x01\xe1\x00\x00\x01\xe2\x00\x00\x01\xe3\x00\x00\x01\xe4\x00\x00\x01\xe5\x00\x00\x01\xe6\x00\x00\x01\xe7\x00\x00\x01\xe8\x00\x00\x01\xe9\x00\x00\x01\xea\x00\x00\x01\xeb\x00\x00\x01\xec\x00\x00\x01\xed\x00\x00\x01\xee\x00\x00\x01\xef\x00\x00\x01\xf0\x00\x00\x01\xf1\x00\x00\x01\xf2\x00\x00\x01\xf3\x00\x00\x01\xf4\x00\x00\x01\xf5\x00\x00\x01\xf6\x00\x00\x01\xf7\x00\x00\x01\xf8\x00\x00\x01\xf9\x00\x00\x01\xfa\x00\x00\x01\xfb\x00\x00\x01\xfc\x00\x00\x01\xfd\x00\x00\x01\xfe\x00\x00\x01\xff' -) +_DGRAM_LONG_LIST = b"/SYNC\x00\x00\x00,[iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii]\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x04\x00\x00\x00\x05\x00\x00\x00\x06\x00\x00\x00\x07\x00\x00\x00\x08\x00\x00\x00\t\x00\x00\x00\n\x00\x00\x00\x0b\x00\x00\x00\x0c\x00\x00\x00\r\x00\x00\x00\x0e\x00\x00\x00\x0f\x00\x00\x00\x10\x00\x00\x00\x11\x00\x00\x00\x12\x00\x00\x00\x13\x00\x00\x00\x14\x00\x00\x00\x15\x00\x00\x00\x16\x00\x00\x00\x17\x00\x00\x00\x18\x00\x00\x00\x19\x00\x00\x00\x1a\x00\x00\x00\x1b\x00\x00\x00\x1c\x00\x00\x00\x1d\x00\x00\x00\x1e\x00\x00\x00\x1f\x00\x00\x00 \x00\x00\x00!\x00\x00\x00\"\x00\x00\x00#\x00\x00\x00$\x00\x00\x00%\x00\x00\x00&\x00\x00\x00'\x00\x00\x00(\x00\x00\x00)\x00\x00\x00*\x00\x00\x00+\x00\x00\x00,\x00\x00\x00-\x00\x00\x00.\x00\x00\x00/\x00\x00\x000\x00\x00\x001\x00\x00\x002\x00\x00\x003\x00\x00\x004\x00\x00\x005\x00\x00\x006\x00\x00\x007\x00\x00\x008\x00\x00\x009\x00\x00\x00:\x00\x00\x00;\x00\x00\x00<\x00\x00\x00=\x00\x00\x00>\x00\x00\x00?\x00\x00\x00@\x00\x00\x00A\x00\x00\x00B\x00\x00\x00C\x00\x00\x00D\x00\x00\x00E\x00\x00\x00F\x00\x00\x00G\x00\x00\x00H\x00\x00\x00I\x00\x00\x00J\x00\x00\x00K\x00\x00\x00L\x00\x00\x00M\x00\x00\x00N\x00\x00\x00O\x00\x00\x00P\x00\x00\x00Q\x00\x00\x00R\x00\x00\x00S\x00\x00\x00T\x00\x00\x00U\x00\x00\x00V\x00\x00\x00W\x00\x00\x00X\x00\x00\x00Y\x00\x00\x00Z\x00\x00\x00[\x00\x00\x00\\\x00\x00\x00]\x00\x00\x00^\x00\x00\x00_\x00\x00\x00`\x00\x00\x00a\x00\x00\x00b\x00\x00\x00c\x00\x00\x00d\x00\x00\x00e\x00\x00\x00f\x00\x00\x00g\x00\x00\x00h\x00\x00\x00i\x00\x00\x00j\x00\x00\x00k\x00\x00\x00l\x00\x00\x00m\x00\x00\x00n\x00\x00\x00o\x00\x00\x00p\x00\x00\x00q\x00\x00\x00r\x00\x00\x00s\x00\x00\x00t\x00\x00\x00u\x00\x00\x00v\x00\x00\x00w\x00\x00\x00x\x00\x00\x00y\x00\x00\x00z\x00\x00\x00{\x00\x00\x00|\x00\x00\x00}\x00\x00\x00~\x00\x00\x00\x7f\x00\x00\x00\x80\x00\x00\x00\x81\x00\x00\x00\x82\x00\x00\x00\x83\x00\x00\x00\x84\x00\x00\x00\x85\x00\x00\x00\x86\x00\x00\x00\x87\x00\x00\x00\x88\x00\x00\x00\x89\x00\x00\x00\x8a\x00\x00\x00\x8b\x00\x00\x00\x8c\x00\x00\x00\x8d\x00\x00\x00\x8e\x00\x00\x00\x8f\x00\x00\x00\x90\x00\x00\x00\x91\x00\x00\x00\x92\x00\x00\x00\x93\x00\x00\x00\x94\x00\x00\x00\x95\x00\x00\x00\x96\x00\x00\x00\x97\x00\x00\x00\x98\x00\x00\x00\x99\x00\x00\x00\x9a\x00\x00\x00\x9b\x00\x00\x00\x9c\x00\x00\x00\x9d\x00\x00\x00\x9e\x00\x00\x00\x9f\x00\x00\x00\xa0\x00\x00\x00\xa1\x00\x00\x00\xa2\x00\x00\x00\xa3\x00\x00\x00\xa4\x00\x00\x00\xa5\x00\x00\x00\xa6\x00\x00\x00\xa7\x00\x00\x00\xa8\x00\x00\x00\xa9\x00\x00\x00\xaa\x00\x00\x00\xab\x00\x00\x00\xac\x00\x00\x00\xad\x00\x00\x00\xae\x00\x00\x00\xaf\x00\x00\x00\xb0\x00\x00\x00\xb1\x00\x00\x00\xb2\x00\x00\x00\xb3\x00\x00\x00\xb4\x00\x00\x00\xb5\x00\x00\x00\xb6\x00\x00\x00\xb7\x00\x00\x00\xb8\x00\x00\x00\xb9\x00\x00\x00\xba\x00\x00\x00\xbb\x00\x00\x00\xbc\x00\x00\x00\xbd\x00\x00\x00\xbe\x00\x00\x00\xbf\x00\x00\x00\xc0\x00\x00\x00\xc1\x00\x00\x00\xc2\x00\x00\x00\xc3\x00\x00\x00\xc4\x00\x00\x00\xc5\x00\x00\x00\xc6\x00\x00\x00\xc7\x00\x00\x00\xc8\x00\x00\x00\xc9\x00\x00\x00\xca\x00\x00\x00\xcb\x00\x00\x00\xcc\x00\x00\x00\xcd\x00\x00\x00\xce\x00\x00\x00\xcf\x00\x00\x00\xd0\x00\x00\x00\xd1\x00\x00\x00\xd2\x00\x00\x00\xd3\x00\x00\x00\xd4\x00\x00\x00\xd5\x00\x00\x00\xd6\x00\x00\x00\xd7\x00\x00\x00\xd8\x00\x00\x00\xd9\x00\x00\x00\xda\x00\x00\x00\xdb\x00\x00\x00\xdc\x00\x00\x00\xdd\x00\x00\x00\xde\x00\x00\x00\xdf\x00\x00\x00\xe0\x00\x00\x00\xe1\x00\x00\x00\xe2\x00\x00\x00\xe3\x00\x00\x00\xe4\x00\x00\x00\xe5\x00\x00\x00\xe6\x00\x00\x00\xe7\x00\x00\x00\xe8\x00\x00\x00\xe9\x00\x00\x00\xea\x00\x00\x00\xeb\x00\x00\x00\xec\x00\x00\x00\xed\x00\x00\x00\xee\x00\x00\x00\xef\x00\x00\x00\xf0\x00\x00\x00\xf1\x00\x00\x00\xf2\x00\x00\x00\xf3\x00\x00\x00\xf4\x00\x00\x00\xf5\x00\x00\x00\xf6\x00\x00\x00\xf7\x00\x00\x00\xf8\x00\x00\x00\xf9\x00\x00\x00\xfa\x00\x00\x00\xfb\x00\x00\x00\xfc\x00\x00\x00\xfd\x00\x00\x00\xfe\x00\x00\x00\xff\x00\x00\x01\x00\x00\x00\x01\x01\x00\x00\x01\x02\x00\x00\x01\x03\x00\x00\x01\x04\x00\x00\x01\x05\x00\x00\x01\x06\x00\x00\x01\x07\x00\x00\x01\x08\x00\x00\x01\t\x00\x00\x01\n\x00\x00\x01\x0b\x00\x00\x01\x0c\x00\x00\x01\r\x00\x00\x01\x0e\x00\x00\x01\x0f\x00\x00\x01\x10\x00\x00\x01\x11\x00\x00\x01\x12\x00\x00\x01\x13\x00\x00\x01\x14\x00\x00\x01\x15\x00\x00\x01\x16\x00\x00\x01\x17\x00\x00\x01\x18\x00\x00\x01\x19\x00\x00\x01\x1a\x00\x00\x01\x1b\x00\x00\x01\x1c\x00\x00\x01\x1d\x00\x00\x01\x1e\x00\x00\x01\x1f\x00\x00\x01 \x00\x00\x01!\x00\x00\x01\"\x00\x00\x01#\x00\x00\x01$\x00\x00\x01%\x00\x00\x01&\x00\x00\x01'\x00\x00\x01(\x00\x00\x01)\x00\x00\x01*\x00\x00\x01+\x00\x00\x01,\x00\x00\x01-\x00\x00\x01.\x00\x00\x01/\x00\x00\x010\x00\x00\x011\x00\x00\x012\x00\x00\x013\x00\x00\x014\x00\x00\x015\x00\x00\x016\x00\x00\x017\x00\x00\x018\x00\x00\x019\x00\x00\x01:\x00\x00\x01;\x00\x00\x01<\x00\x00\x01=\x00\x00\x01>\x00\x00\x01?\x00\x00\x01@\x00\x00\x01A\x00\x00\x01B\x00\x00\x01C\x00\x00\x01D\x00\x00\x01E\x00\x00\x01F\x00\x00\x01G\x00\x00\x01H\x00\x00\x01I\x00\x00\x01J\x00\x00\x01K\x00\x00\x01L\x00\x00\x01M\x00\x00\x01N\x00\x00\x01O\x00\x00\x01P\x00\x00\x01Q\x00\x00\x01R\x00\x00\x01S\x00\x00\x01T\x00\x00\x01U\x00\x00\x01V\x00\x00\x01W\x00\x00\x01X\x00\x00\x01Y\x00\x00\x01Z\x00\x00\x01[\x00\x00\x01\\\x00\x00\x01]\x00\x00\x01^\x00\x00\x01_\x00\x00\x01`\x00\x00\x01a\x00\x00\x01b\x00\x00\x01c\x00\x00\x01d\x00\x00\x01e\x00\x00\x01f\x00\x00\x01g\x00\x00\x01h\x00\x00\x01i\x00\x00\x01j\x00\x00\x01k\x00\x00\x01l\x00\x00\x01m\x00\x00\x01n\x00\x00\x01o\x00\x00\x01p\x00\x00\x01q\x00\x00\x01r\x00\x00\x01s\x00\x00\x01t\x00\x00\x01u\x00\x00\x01v\x00\x00\x01w\x00\x00\x01x\x00\x00\x01y\x00\x00\x01z\x00\x00\x01{\x00\x00\x01|\x00\x00\x01}\x00\x00\x01~\x00\x00\x01\x7f\x00\x00\x01\x80\x00\x00\x01\x81\x00\x00\x01\x82\x00\x00\x01\x83\x00\x00\x01\x84\x00\x00\x01\x85\x00\x00\x01\x86\x00\x00\x01\x87\x00\x00\x01\x88\x00\x00\x01\x89\x00\x00\x01\x8a\x00\x00\x01\x8b\x00\x00\x01\x8c\x00\x00\x01\x8d\x00\x00\x01\x8e\x00\x00\x01\x8f\x00\x00\x01\x90\x00\x00\x01\x91\x00\x00\x01\x92\x00\x00\x01\x93\x00\x00\x01\x94\x00\x00\x01\x95\x00\x00\x01\x96\x00\x00\x01\x97\x00\x00\x01\x98\x00\x00\x01\x99\x00\x00\x01\x9a\x00\x00\x01\x9b\x00\x00\x01\x9c\x00\x00\x01\x9d\x00\x00\x01\x9e\x00\x00\x01\x9f\x00\x00\x01\xa0\x00\x00\x01\xa1\x00\x00\x01\xa2\x00\x00\x01\xa3\x00\x00\x01\xa4\x00\x00\x01\xa5\x00\x00\x01\xa6\x00\x00\x01\xa7\x00\x00\x01\xa8\x00\x00\x01\xa9\x00\x00\x01\xaa\x00\x00\x01\xab\x00\x00\x01\xac\x00\x00\x01\xad\x00\x00\x01\xae\x00\x00\x01\xaf\x00\x00\x01\xb0\x00\x00\x01\xb1\x00\x00\x01\xb2\x00\x00\x01\xb3\x00\x00\x01\xb4\x00\x00\x01\xb5\x00\x00\x01\xb6\x00\x00\x01\xb7\x00\x00\x01\xb8\x00\x00\x01\xb9\x00\x00\x01\xba\x00\x00\x01\xbb\x00\x00\x01\xbc\x00\x00\x01\xbd\x00\x00\x01\xbe\x00\x00\x01\xbf\x00\x00\x01\xc0\x00\x00\x01\xc1\x00\x00\x01\xc2\x00\x00\x01\xc3\x00\x00\x01\xc4\x00\x00\x01\xc5\x00\x00\x01\xc6\x00\x00\x01\xc7\x00\x00\x01\xc8\x00\x00\x01\xc9\x00\x00\x01\xca\x00\x00\x01\xcb\x00\x00\x01\xcc\x00\x00\x01\xcd\x00\x00\x01\xce\x00\x00\x01\xcf\x00\x00\x01\xd0\x00\x00\x01\xd1\x00\x00\x01\xd2\x00\x00\x01\xd3\x00\x00\x01\xd4\x00\x00\x01\xd5\x00\x00\x01\xd6\x00\x00\x01\xd7\x00\x00\x01\xd8\x00\x00\x01\xd9\x00\x00\x01\xda\x00\x00\x01\xdb\x00\x00\x01\xdc\x00\x00\x01\xdd\x00\x00\x01\xde\x00\x00\x01\xdf\x00\x00\x01\xe0\x00\x00\x01\xe1\x00\x00\x01\xe2\x00\x00\x01\xe3\x00\x00\x01\xe4\x00\x00\x01\xe5\x00\x00\x01\xe6\x00\x00\x01\xe7\x00\x00\x01\xe8\x00\x00\x01\xe9\x00\x00\x01\xea\x00\x00\x01\xeb\x00\x00\x01\xec\x00\x00\x01\xed\x00\x00\x01\xee\x00\x00\x01\xef\x00\x00\x01\xf0\x00\x00\x01\xf1\x00\x00\x01\xf2\x00\x00\x01\xf3\x00\x00\x01\xf4\x00\x00\x01\xf5\x00\x00\x01\xf6\x00\x00\x01\xf7\x00\x00\x01\xf8\x00\x00\x01\xf9\x00\x00\x01\xfa\x00\x00\x01\xfb\x00\x00\x01\xfc\x00\x00\x01\xfd\x00\x00\x01\xfe\x00\x00\x01\xff" class TestOscMessage(unittest.TestCase): @@ -120,7 +112,7 @@ def test_complex_array_params(self): self.assertEqual(3, len(list(msg))) def test_raises_on_empty_datargram(self): - self.assertRaises(osc_message.ParseError, osc_message.OscMessage, b'') + self.assertRaises(osc_message.ParseError, osc_message.OscMessage, b"") def test_ignores_unknown_param(self): msg = osc_message.OscMessage(_DGRAM_UNKNOWN_PARAM_TYPE) @@ -130,16 +122,15 @@ def test_ignores_unknown_param(self): self.assertAlmostEqual(0.5, msg.params[0]) def test_raises_on_invalid_array(self): - self.assertRaises(osc_message.ParseError, - osc_message.OscMessage, - b"/SYNC\x00\x00\x00[]]\x00") - self.assertRaises(osc_message.ParseError, - osc_message.OscMessage, - b"/SYNC\x00\x00\x00[[]\x00") + self.assertRaises( + osc_message.ParseError, osc_message.OscMessage, b"/SYNC\x00\x00\x00[]]\x00" + ) + self.assertRaises( + osc_message.ParseError, osc_message.OscMessage, b"/SYNC\x00\x00\x00[[]\x00" + ) def test_raises_on_incorrect_datargram(self): - self.assertRaises( - osc_message.ParseError, osc_message.OscMessage, b'foobar') + self.assertRaises(osc_message.ParseError, osc_message.OscMessage, b"foobar") def test_parse_long_params_list(self): msg = osc_message.OscMessage(_DGRAM_LONG_LIST) diff --git a/pythonosc/test/test_osc_message_builder.py b/pythonosc/test/test_osc_message_builder.py index 5208ad9..abd2f3b 100644 --- a/pythonosc/test/test_osc_message_builder.py +++ b/pythonosc/test/test_osc_message_builder.py @@ -9,7 +9,7 @@ def test_just_address(self): self.assertEqual("/a/b/c", msg.address) self.assertEqual([], msg.params) # Messages with just an address should still contain the ",". - self.assertEqual(b'/a/b/c\x00\x00,\x00\x00\x00', msg.dgram) + self.assertEqual(b"/a/b/c\x00\x00,\x00\x00\x00", msg.dgram) def test_no_address_raises(self): builder = osc_message_builder.OscMessageBuilder("") @@ -20,8 +20,8 @@ def test_wrong_param_raise(self): self.assertRaises(ValueError, builder.add_arg, "what?", 1) def test_add_arg_invalid_infered_type(self): - builder = osc_message_builder.OscMessageBuilder('') - self.assertRaises(ValueError, builder.add_arg, {'name': 'John'}) + builder = osc_message_builder.OscMessageBuilder("") + self.assertRaises(ValueError, builder.add_arg, {"name": "John"}) def test_all_param_types(self): builder = osc_message_builder.OscMessageBuilder(address="/SYNC") @@ -49,13 +49,25 @@ def test_all_param_types(self): builder.add_arg(1e-9, builder.ARG_TYPE_DOUBLE) self.assertEqual(len("fihsTFb[i[s]]N") * 2 + 3, len(builder.args)) self.assertEqual("/SYNC", builder.address) - builder.address = '/SEEK' + builder.address = "/SEEK" msg = builder.build() self.assertEqual("/SEEK", msg.address) self.assertSequenceEqual( - [4.0, 2, 1099511627776, "value", True, False, b"\x01\x02\x03", [1, ["abc"]], None] * 2 + - [4278255360, (1, 145, 36, 125), 1e-9], - msg.params) + [ + 4.0, + 2, + 1099511627776, + "value", + True, + False, + b"\x01\x02\x03", + [1, ["abc"]], + None, + ] + * 2 + + [4278255360, (1, 145, 36, 125), 1e-9], + msg.params, + ) def test_long_list(self): huge_list = list(range(512)) @@ -67,18 +79,18 @@ def test_long_list(self): def test_build_wrong_type_raises(self): builder = osc_message_builder.OscMessageBuilder(address="/SYNC") - builder.add_arg('this is not a float', builder.ARG_TYPE_FLOAT) + builder.add_arg("this is not a float", builder.ARG_TYPE_FLOAT) self.assertRaises(osc_message_builder.BuildError, builder.build) def test_build_noarg_message(self): - msg = osc_message_builder.OscMessageBuilder(address='/SYNC').build() + msg = osc_message_builder.OscMessageBuilder(address="/SYNC").build() # This reference message was generated with Cycling 74's Max software # and then was intercepted with Wireshark - reference = bytearray.fromhex('2f53594e430000002c000000') + reference = bytearray.fromhex("2f53594e430000002c000000") self.assertSequenceEqual(msg._dgram, reference) def test_bool_encoding(self): - builder = osc_message_builder.OscMessageBuilder('') + builder = osc_message_builder.OscMessageBuilder("") builder.add_arg(0) builder.add_arg(1) builder.add_arg(False) diff --git a/pythonosc/test/test_osc_packet.py b/pythonosc/test/test_osc_packet.py index 997f26b..0d150f2 100644 --- a/pythonosc/test/test_osc_packet.py +++ b/pythonosc/test/test_osc_packet.py @@ -14,11 +14,10 @@ b"\x00\x00\x00\x10" b"/SYNC\x00\x00\x00" b",f\x00\x00" - b"?\x00\x00\x00") + b"?\x00\x00\x00" +) -_DGRAM_EMPTY_BUNDLE = ( - b"#bundle\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x01") +_DGRAM_EMPTY_BUNDLE = b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" _DGRAM_NESTED_MESS = ( b"#bundle\x00" @@ -50,7 +49,8 @@ b"\x00\x00\x00\x10" b"/4444\x00\x00\x00" b",f\x00\x00" - b"?\x00\x00\x00") + b"?\x00\x00\x00" +) class TestOscPacket(unittest.TestCase): @@ -59,7 +59,7 @@ def test_two_messages_in_a_bundle(self): self.assertEqual(2, len(packet.messages)) def test_empty_dgram_raises_exception(self): - self.assertRaises(osc_packet.ParseError, osc_packet.OscPacket, b'') + self.assertRaises(osc_packet.ParseError, osc_packet.OscPacket, b"") def test_empty_bundle(self): packet = osc_packet.OscPacket(_DGRAM_EMPTY_BUNDLE) diff --git a/pythonosc/test/test_osc_server.py b/pythonosc/test/test_osc_server.py index 39ac294..1b38b1b 100644 --- a/pythonosc/test/test_osc_server.py +++ b/pythonosc/test/test_osc_server.py @@ -4,25 +4,19 @@ from pythonosc import dispatcher from pythonosc import osc_server -_SIMPLE_PARAM_INT_MSG = ( - b"/SYNC\x00\x00\x00" - b",i\x00\x00" - b"\x00\x00\x00\x04") +_SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00" b",i\x00\x00" b"\x00\x00\x00\x04" # Regression test for a datagram that should NOT be stripped, ever... -_SIMPLE_PARAM_INT_9 = b'/debug\x00\x00,i\x00\x00\x00\x00\x00\t' +_SIMPLE_PARAM_INT_9 = b"/debug\x00\x00,i\x00\x00\x00\x00\x00\t" _SIMPLE_MSG_NO_PARAMS = b"/SYNC\x00\x00\x00" class TestOscServer(unittest.TestCase): def test_is_valid_request(self): - self.assertTrue( - osc_server._is_valid_request((b'#bundle\x00foobar',))) - self.assertTrue( - osc_server._is_valid_request((b'/address/1/2/3,foobar',))) - self.assertFalse( - osc_server._is_valid_request((b'',))) + self.assertTrue(osc_server._is_valid_request((b"#bundle\x00foobar",))) + self.assertTrue(osc_server._is_valid_request((b"/address/1/2/3,foobar",))) + self.assertFalse(osc_server._is_valid_request((b"",))) class TestUDPHandler(unittest.TestCase): @@ -33,28 +27,32 @@ def setUp(self): self.server = unittest.mock.Mock(spec=osc_server.BlockingOSCUDPServer) # Need to attach property mocks to types, not objects... weird. type(self.server).dispatcher = unittest.mock.PropertyMock( - return_value=self.dispatcher) + return_value=self.dispatcher + ) self.client_address = ("127.0.0.1", 8080) def test_no_match(self): mock_meth = unittest.mock.MagicMock() self.dispatcher.map("/foobar", mock_meth) osc_server._UDPHandler( - [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server) + [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server + ) self.assertFalse(mock_meth.called) def test_match_with_args(self): mock_meth = unittest.mock.MagicMock() self.dispatcher.map("/SYNC", mock_meth, 1, 2, 3) osc_server._UDPHandler( - [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server) + [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server + ) mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) def test_match_int9(self): mock_meth = unittest.mock.MagicMock() self.dispatcher.map("/debug", mock_meth) osc_server._UDPHandler( - [_SIMPLE_PARAM_INT_9, None], self.client_address, self.server) + [_SIMPLE_PARAM_INT_9, None], self.client_address, self.server + ) self.assertTrue(mock_meth.called) mock_meth.assert_called_with("/debug", 9) @@ -62,14 +60,16 @@ def test_match_without_args(self): mock_meth = unittest.mock.MagicMock() self.dispatcher.map("/SYNC", mock_meth) osc_server._UDPHandler( - [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server) + [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server + ) mock_meth.assert_called_with("/SYNC") def test_match_default_handler(self): mock_meth = unittest.mock.MagicMock() self.dispatcher.set_default_handler(mock_meth) osc_server._UDPHandler( - [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server) + [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server + ) mock_meth.assert_called_with("/SYNC") diff --git a/pythonosc/test/test_udp_client.py b/pythonosc/test/test_udp_client.py index f4eb570..dece381 100644 --- a/pythonosc/test/test_udp_client.py +++ b/pythonosc/test/test_udp_client.py @@ -6,21 +6,21 @@ class TestUdpClient(unittest.TestCase): - @mock.patch('socket.socket') + @mock.patch("socket.socket") def test_send(self, mock_socket_ctor): mock_socket = mock_socket_ctor.return_value - client = udp_client.UDPClient('::1', 31337) + client = udp_client.UDPClient("::1", 31337) - msg = osc_message_builder.OscMessageBuilder('/').build() + msg = osc_message_builder.OscMessageBuilder("/").build() client.send(msg) self.assertTrue(mock_socket.sendto.called) - mock_socket.sendto.assert_called_once_with(msg.dgram, ('::1', 31337)) + mock_socket.sendto.assert_called_once_with(msg.dgram, ("::1", 31337)) class TestSimpleUdpClient(unittest.TestCase): def setUp(self): - self.patcher = mock.patch('pythonosc.udp_client.OscMessageBuilder') + self.patcher = mock.patch("pythonosc.udp_client.OscMessageBuilder") self.patcher.start() self.builder = udp_client.OscMessageBuilder.return_value self.msg = self.builder.build.return_value @@ -30,20 +30,21 @@ def tearDown(self): self.patcher.stop() def test_send_message_calls_send_with_msg(self): - udp_client.SimpleUDPClient.send_message(self.client, '/address', 1) + udp_client.SimpleUDPClient.send_message(self.client, "/address", 1) self.client.send.assert_called_once_with(self.msg) def test_send_message_calls_add_arg_with_value(self): - udp_client.SimpleUDPClient.send_message(self.client, '/address', 1) + udp_client.SimpleUDPClient.send_message(self.client, "/address", 1) self.builder.add_arg.assert_called_once_with(1) def test_send_message_calls_add_arg_once_with_string(self): - udp_client.SimpleUDPClient.send_message(self.client, '/address', 'hello') - self.builder.add_arg.assert_called_once_with('hello') + udp_client.SimpleUDPClient.send_message(self.client, "/address", "hello") + self.builder.add_arg.assert_called_once_with("hello") def test_send_message_calls_add_arg_multiple_times_with_list(self): - udp_client.SimpleUDPClient.send_message(self.client, '/address', - [1, 'john', True]) + udp_client.SimpleUDPClient.send_message( + self.client, "/address", [1, "john", True] + ) self.assertEqual(self.builder.add_arg.call_count, 3) diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index 20b0f17..c4cce00 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -19,7 +19,13 @@ class UDPClient(object): """OSC client to send :class:`OscMessage` or :class:`OscBundle` via UDP""" - def __init__(self, address: str, port: int, allow_broadcast: bool = False, family: socket.AddressFamily = socket.AF_UNSPEC) -> None: + def __init__( + self, + address: str, + port: int, + allow_broadcast: bool = False, + family: socket.AddressFamily = socket.AF_UNSPEC, + ) -> None: """Initialize client As this is UDP it will not actually make any attempt to connect to the @@ -32,7 +38,9 @@ def __init__(self, address: str, port: int, allow_broadcast: bool = False, famil family: address family parameter (passed to socket.getaddrinfo) """ - for addr in socket.getaddrinfo(address, port, type=socket.SOCK_DGRAM, family=family): + for addr in socket.getaddrinfo( + address, port, type=socket.SOCK_DGRAM, family=family + ): af, socktype, protocol, canonname, sa = addr try: diff --git a/scripts/print_datagrams_main.py b/scripts/print_datagrams_main.py index 5c2f63d..ff26d0c 100644 --- a/scripts/print_datagrams_main.py +++ b/scripts/print_datagrams_main.py @@ -6,15 +6,8 @@ def main(): parser = argparse.ArgumentParser() - parser.add_argument( - "--ip", - default="127.0.0.1", - help="The ip to listen on") - parser.add_argument( - "--port", - type=int, - default=5005, - help="The port to listen on") + parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") + parser.add_argument("--port", type=int, default=5005, help="The port to listen on") args = parser.parse_args() _PrintOscMessages(args.ip, args.port) From ee0a3ecddc701ad891e92ebc5db3ac575f30a99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 12:53:24 +0000 Subject: [PATCH 16/69] remove miscommitted file about types --- pythonosc/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 pythonosc/py.typed diff --git a/pythonosc/py.typed b/pythonosc/py.typed deleted file mode 100644 index e69de29..0000000 From 21ba85cdd21ecbcb4f99af309bbfc620e2f95251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 12:56:59 +0000 Subject: [PATCH 17/69] use black in same action as the tests --- .github/workflows/black.yml | 12 ------------ .github/workflows/python-test.yml | 12 ++++++------ 2 files changed, 6 insertions(+), 18 deletions(-) delete mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 7afeb0b..0000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Lint with Black - -on: [push, pull_request] - -permissions: read-all - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 8e7088e..3d20d34 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -5,15 +5,10 @@ name: Test permissions: read-all -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] +on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest strategy: fail-fast: false @@ -40,3 +35,8 @@ jobs: run: mypy pythonosc examples - name: Test with pytest run: pytest + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: psf/black@stable From 787dde960dbf097fd9dca5ad7745974ee855e4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 13:08:48 +0000 Subject: [PATCH 18/69] add test matrix support for python 3.12, only check the latest 3 stable releases and remove flake8 checks conflicting with black --- .github/workflows/publish-pypi.yml | 4 ++-- .github/workflows/python-test.yml | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 4c6a52a..da0fcad 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -8,9 +8,9 @@ jobs: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install pypa/build diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 3d20d34..79cd04f 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -13,12 +13,12 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -29,8 +29,6 @@ jobs: run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --statistics - name: Check with mypy run: mypy pythonosc examples - name: Test with pytest From d9a29a073d1f379e1a602956952b11b9a5bed335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Fri, 26 Jul 2024 13:12:42 +0000 Subject: [PATCH 19/69] mention changelog in contributing docs and bump project reqs to 3.10. Concretely it still works with >=3.7 but because we only support the latest 3 versions it's more consistent this way. --- CONTRIBUTING.md | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0146cae..49c0a37 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,5 +6,6 @@ TL;DR: - Format all code with Black - Provide type annotations with mypy - Write and run tests with pytest +- If you're adding a new feature, mention it in the [CHANGELOG.md](CHANGELOG.md) file under the _Unreleased_ section Please only send the PR once all of the above is done, thanks! \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5a3cfbf..e1277dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "python-osc" version="1.8.3" description="Open Sound Control server and client implementations in pure Python" readme="README.rst" -requires-python=">=3.7" +requires-python=">=3.10" license = {file = "LICENSE.txt"} authors = [ {name = "attwad", email = "tmusoft@gmail.com"}, From d89c241563f951f988ecab98799007144ab51ba5 Mon Sep 17 00:00:00 2001 From: Bob Haddleton Date: Sun, 21 Jul 2024 11:11:33 -0500 Subject: [PATCH 20/69] Add tcp support --- .gitignore | 3 + examples/async_simple_tcp_client.py | 49 ++++ examples/async_tcp_server.py | 46 +++ examples/simple_tcp_client.py | 31 ++ examples/simple_tcp_server.py | 43 +++ pythonosc/osc_message.py | 3 + pythonosc/osc_message_builder.py | 14 + pythonosc/osc_tcp_server.py | 392 ++++++++++++++++++++++++++ pythonosc/slip.py | 82 ++++++ pythonosc/tcp_client.py | 213 ++++++++++++++ pythonosc/test/test_osc_tcp_server.py | 275 ++++++++++++++++++ pythonosc/test/test_tcp_client.py | 69 +++++ 12 files changed, 1220 insertions(+) create mode 100644 examples/async_simple_tcp_client.py create mode 100644 examples/async_tcp_server.py create mode 100644 examples/simple_tcp_client.py create mode 100644 examples/simple_tcp_server.py create mode 100644 pythonosc/osc_tcp_server.py create mode 100644 pythonosc/slip.py create mode 100644 pythonosc/tcp_client.py create mode 100644 pythonosc/test/test_osc_tcp_server.py create mode 100644 pythonosc/test/test_tcp_client.py diff --git a/.gitignore b/.gitignore index 5c4fe8e..78d03e3 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ bin/ # PyBuilder target/ + +# PyCharm +.idea/ diff --git a/examples/async_simple_tcp_client.py b/examples/async_simple_tcp_client.py new file mode 100644 index 0000000..b5297c7 --- /dev/null +++ b/examples/async_simple_tcp_client.py @@ -0,0 +1,49 @@ +"""Small example Asynchronous OSC TCP client + +This program listens for incoming messages in one task, and +sends 10 random values between 0.0 and 1.0 to the /filter address, +waiting for 1 seconds between each value in a second task. +""" +import argparse +import asyncio +import random +import sys + +from pythonosc import tcp_client + + +async def get_messages(client): + async for msg in client.get_messages(60): + print(msg) + + +async def send_messages(client): + for x in range(10): + r = random.random() + print(f"Sending /filter {r}") + await client.send_message("/filter", r) + await asyncio.sleep(1) + + +async def init_main(): + parser = argparse.ArgumentParser() + parser.add_argument("--ip", default="127.0.0.1", + help="The ip of the OSC server") + parser.add_argument("--port", type=int, default=5005, + help="The port the OSC server is listening on") + parser.add_argument("--mode", default="1.1", + help="The OSC protocol version of the server (default is 1.1)") + args = parser.parse_args() + + async with tcp_client.AsyncSimpleTCPClient(args.ip, args.port, mode=args.mode) as client: + async with asyncio.TaskGroup() as tg: + tg.create_task(get_messages(client)) + tg.create_task(send_messages(client)) + +if sys.version_info >= (3, 7): + asyncio.run(init_main()) +else: + # TODO(python-upgrade): drop this once 3.6 is no longer supported + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(init_main()) + event_loop.close() diff --git a/examples/async_tcp_server.py b/examples/async_tcp_server.py new file mode 100644 index 0000000..873ac3d --- /dev/null +++ b/examples/async_tcp_server.py @@ -0,0 +1,46 @@ +import argparse +import asyncio +import sys + +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_tcp_server import AsyncOSCTCPServer + + +def filter_handler(address, *args): + print(f"{address}: {args}") + + +dispatcher = Dispatcher() +dispatcher.map("/filter", filter_handler) + + +async def loop(): + """Example main loop that only runs for 10 iterations before finishing""" + for i in range(10): + print(f"Loop {i}") + await asyncio.sleep(10) + + +async def init_main(): + parser = argparse.ArgumentParser() + parser.add_argument("--ip", default="127.0.0.1", + help="The ip of the OSC server") + parser.add_argument("--port", type=int, default=5005, + help="The port the OSC server is listening on") + parser.add_argument("--mode", default="1.1", + help="The OSC protocol version of the server (default is 1.1)") + args = parser.parse_args() + + async with AsyncOSCTCPServer(args.ip, args.port, dispatcher, mode=args.mode) as server: + async with asyncio.TaskGroup() as tg: + tg.create_task(server.start()) + tg.create_task(loop()) + + +if sys.version_info >= (3, 7): + asyncio.run(init_main()) +else: + # TODO(python-upgrade): drop this once 3.6 is no longer supported + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(init_main()) + event_loop.close() diff --git a/examples/simple_tcp_client.py b/examples/simple_tcp_client.py new file mode 100644 index 0000000..53d319c --- /dev/null +++ b/examples/simple_tcp_client.py @@ -0,0 +1,31 @@ +"""Small example OSC client + +This program sends 10 random values between 0.0 and 1.0 to the /filter address, +and listens for incoming messages for 1 second between each value. +""" +import argparse +import random + +from pythonosc import tcp_client + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--ip", default="127.0.0.1", + help="The ip of the OSC server") + parser.add_argument("--port", type=int, default=5005, + help="The port the OSC server is listening on") + parser.add_argument("--mode", default="1.1", + help="The OSC protocol version of the server (default is 1.1)") + args = parser.parse_args() + + with tcp_client.SimpleTCPClient(args.ip, args.port, mode=args.mode) as client: + for x in range(10): + n = random.random() + print(f"Sending /filter {n}") + client.send_message("/filter", n) + resp = client.get_messages(1) + for r in resp: + try: + print(r) + except Exception as e: + print(f"oops {str(e)}: {r}") diff --git a/examples/simple_tcp_server.py b/examples/simple_tcp_server.py new file mode 100644 index 0000000..c5883ea --- /dev/null +++ b/examples/simple_tcp_server.py @@ -0,0 +1,43 @@ +"""Small example OSC server + +This program listens to the specified address and port, and prints some information about +received packets. +""" +import argparse +import math + +from pythonosc import osc_tcp_server +from pythonosc.dispatcher import Dispatcher + + +def print_volume_handler(unused_addr, args, volume): + print("[{0}] ~ {1}".format(args[0], volume)) + + +def print_compute_handler(unused_addr, args, volume): + try: + print("[{0}] ~ {1}".format(args[0], args[1](volume))) + except ValueError: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--ip", + default="127.0.0.1", help="The ip to listen on") + parser.add_argument("--port", + type=int, default=5005, help="The port to listen on") + parser.add_argument("--mode", default="1.1", + help="The OSC protocol version of the server (default is 1.1)") + + args = parser.parse_args() + + dispatcher = Dispatcher() + dispatcher.map("/filter", print) + dispatcher.map("/volume", print_volume_handler, "Volume") + dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) + + server = osc_tcp_server.ThreadingOSCTCPServer( + (args.ip, args.port), dispatcher, mode=args.mode) + print("Serving on {}".format(server.server_address)) + server.serve_forever() diff --git a/pythonosc/osc_message.py b/pythonosc/osc_message.py index 3d46551..180372d 100644 --- a/pythonosc/osc_message.py +++ b/pythonosc/osc_message.py @@ -22,6 +22,9 @@ def __init__(self, dgram: bytes) -> None: self._parameters = [] # type: List[Any] self._parse_datagram() + def __str__(self): + return f"{self.address} {' '.join(str(p) for p in self.params)}" + def _parse_datagram(self) -> None: try: self._address_regexp, index = osc_types.get_string(self._dgram, 0) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index e91b76c..ffbb1e5 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -1,4 +1,5 @@ """Build OSC messages for client applications.""" +from typing import Iterable from pythonosc import osc_message from pythonosc.parsing import osc_types @@ -195,3 +196,16 @@ def build(self) -> osc_message.OscMessage: return osc_message.OscMessage(dgram) except osc_types.BuildError as be: raise BuildError("Could not build the message: {}".format(be)) + + +def build_msg(address: str, value: ArgValue): + builder = OscMessageBuilder(address=address) + if value is None: + values = [] + elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): + values = [value] + else: + values = value + for val in values: + builder.add_arg(val) + return builder.build() \ No newline at end of file diff --git a/pythonosc/osc_tcp_server.py b/pythonosc/osc_tcp_server.py new file mode 100644 index 0000000..c648638 --- /dev/null +++ b/pythonosc/osc_tcp_server.py @@ -0,0 +1,392 @@ +# TODO: timeouts! + + +"""OSC Servers that receive TCP packets and invoke handlers accordingly. + +Use like this: + +dispatcher = dispatcher.Dispatcher() +# This will print all parameters to stdout. +dispatcher.map("/bpm", print) +server = ForkingOSCTCPServer((ip, port), dispatcher) +server.serve_forever() + +or run the server on its own thread: +server = ForkingOSCTCPServer((ip, port), dispatcher) +server_thread = threading.Thread(target=server.serve_forever) +server_thread.start() +... +server.shutdown() + + +Those servers are using the standard socketserver from the standard library: +http://docs.python.org/library/socketserver.html + + +Alternatively, the AsyncIOOSCTCPServer server can be integrated with an +asyncio event loop: + +loop = asyncio.get_event_loop() +server = AsyncIOOSCTCPServer(server_address, dispatcher) +server.serve() +loop.run_forever() + +""" + +import asyncio +import inspect +import logging +import os +import socketserver +import struct +import time + +from pythonosc import osc_message_builder, osc_packet, slip + +LOG = logging.getLogger() +MODE_1_0 = "1.0" +MODE_1_1 = "1.1" + + +def _call_handlers_for_packet(data, dispatcher): + """ + This function calls the handlers registered to the dispatcher for + every message it found in the packet. + The process/thread granularity is thus the OSC packet, not the handler. + + If parameters were registered with the dispatcher, then the handlers are + called this way: + handler('/address that triggered the message', + registered_param_list, osc_msg_arg1, osc_msg_arg2, ...) + if no parameters were registered, then it is just called like this: + handler('/address that triggered the message', + osc_msg_arg1, osc_msg_arg2, osc_msg_param3, ...) + """ + + # Get OSC messages from all bundles or standalone message. + all_resp = [] + try: + LOG.debug("_call_handlers_for_packet: data ", data) + packet = osc_packet.OscPacket(data) + for timed_msg in packet.messages: + now = time.time() + handlers = dispatcher.handlers_for_address( + timed_msg.message.address) + if not handlers: + continue + # If the message is to be handled later, then so be it. + if timed_msg.time > now: + time.sleep(timed_msg.time - now) + for handler in handlers: + if handler.args: + resp = handler.callback( + timed_msg.message.address, handler.args, *timed_msg.message) + else: + resp = handler.callback(timed_msg.message.address, *timed_msg.message) + if resp: + all_resp.append(resp) + except osc_packet.ParseError: + pass + return all_resp + + +class _TCPHandler1_0(socketserver.BaseRequestHandler): + """Handles correct OSC1.0 messages. + + Whether this will be run on its own thread, the server's or a whole new + process depends on the server you instantiated, look at their documentation. + + This method is called after a basic sanity check was done on the datagram, + basically whether this datagram looks like an osc message or bundle, + if not the server won't even bother to call it and so no new + threads/processes will be spawned. + """ + def handle(self): + LOG.debug("handle OSC 1.0 protocol") + while True: + lengthbuf = self.recvall(4) + if lengthbuf is None: + break + length, = struct.unpack('!I', lengthbuf) + data = self.recvall(length) + if data is None: + break + + resp = _call_handlers_for_packet(data, self.server.dispatcher) + for r in resp: + if r is not None: + if not isinstance(r, list): + r = [r] + msg = osc_message_builder.build_msg(r[0], r[1:]) + b = struct.pack('!I', len(msg.dgram)) + self.request.sendall(b + msg.dgram) + + def recvall(self, count): + buf = b'' + while count > 0: + newbuf = self.request.recv(count) + if not newbuf: + return None + buf += newbuf + count -= len(newbuf) + return buf + + +class _TCPHandler1_1(socketserver.BaseRequestHandler): + """Handles correct OSC1.1 messages. + + Whether this will be run on its own thread, the server's or a whole new + process depends on the server you instantiated, look at their documentation. + + This method is called after a basic sanity check was done on the datagram, + basically whether this datagram looks like an osc message or bundle, + if not the server won't even bother to call it and so no new + threads/processes will be spawned. + """ + def handle(self): + LOG.debug("handle OSC 1.1 protocol") + while True: + packets = self.recvall() + if packets is None: + break + + for p in packets: + resp = _call_handlers_for_packet(p, self.server.dispatcher) + for r in resp: + if not isinstance(r, list): + r = [r] + msg = osc_message_builder.build_msg(r[0], r[1:]) + self.request.sendall(slip.encode(msg.dgram)) + + def recvall(self): + buf = self.request.recv(4096) + if not buf: + return None + # If the last byte is not an END marker there could be more data coming + while buf[-1] != 192: + newbuf = self.request.recv(4096) + if not newbuf: + # Maybe should raise an exception here? + break + buf += newbuf + + packets = [slip.decode(p) for p in buf.split(slip.END_END)] + return packets + + +class OSCTCPServer(socketserver.TCPServer): + """Superclass for different flavors of OSCTCPServer""" + + def __init__(self, server_address, dispatcher, mode: str = MODE_1_1): + self.request_queue_size = 300 + self.mode = mode + if mode not in [MODE_1_0, MODE_1_1]: + raise ValueError("OSC Mode must be '1.0' or '1.1'") + if self.mode == MODE_1_0: + super().__init__(server_address, _TCPHandler1_0) + else: + super().__init__(server_address, _TCPHandler1_1) + self._dispatcher = dispatcher + + def verify_request(self, request, client_address): + """Returns true if the data looks like a valid OSC TCP datagram.""" + # d = request.recv(9999).decode("utf-8") + # print("d:type=%s d=%s" % (type(d), d)) + return True + + @property + def dispatcher(self): + """Dispatcher accessor for handlers to dispatch osc messages.""" + return self._dispatcher + + +class BlockingOSCTCPServer(OSCTCPServer): + """Blocking version of the TCP server. + + Each message will be handled sequentially on the same thread. + Use this is you don't care about latency in your message handling or don't + have a multiprocess/multithread environment (really?). + """ + + +class ThreadingOSCTCPServer(socketserver.ThreadingMixIn, OSCTCPServer): + """Threading version of the OSC TCP server. + + Each message will be handled in its own new thread. + Use this when lightweight operations are done by each message handlers. + """ + + +if hasattr(os, "fork"): + class ForkingOSCTCPServer(socketserver.ForkingMixIn, OSCTCPServer): + """Forking version of the OSC TCP server. + + Each message will be handled in its own new process. + Use this when heavyweight operations are done by each message handlers + and forking a whole new process for each of them is worth it. + """ + + +class AsyncOSCTCPServer: + """Asyncio version of the OSC TCP Server. + Each TCP message is handled by _call_handlers_for_packet, the same method as in the + OSCTCPServer family of blocking, threading, and forking servers + """ + + def __init__(self, server_address: str, port: int, dispatcher, mode: str = MODE_1_1): + """ + :param server_address: tuple of (IP address to bind to, port) + :param dispatcher: a pythonosc.dispatcher.Dispatcher + """ + self._port = port + self._server_address = server_address + self._dispatcher = dispatcher + self._server = None + self._mode = mode + + # class _OSCProtocolFactory(asyncio.DatagramProtocol): + # """OSC protocol factory which passes datagrams to _call_handlers_for_packet""" + # + # def __init__(self, dispatcher): + # self.dispatcher = dispatcher + # + # def datagram_received(self, data, unused_addr): + # _call_handlers_for_packet(data, self.dispatcher) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.stop() + + async def start(self): + """creates a socket endpoint and registers it with our event loop""" + self._server = await asyncio.start_server( + self.handle, self._server_address, self._port) + + addrs = ', '.join(str(sock.getsockname()) for sock in self._server.sockets) + LOG.debug(f'Serving on {addrs}') + + async with self._server: + await self._server.serve_forever() + + async def stop(self): + await self._server.cancel() + + @property + def dispatcher(self): + return self._dispatcher + + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + if self._mode == MODE_1_1: + await self.handle_1_1(reader, writer) + else: + await self.handle1_0(reader, writer) + writer.write_eof() + LOG.debug("Close the connection") + writer.close() + await writer.wait_closed() + + async def handle1_0(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + LOG.debug("Incoming socket open 1.0") + while True: + try: + buf = await reader.read(4) + except Exception as e: + LOG.exception("Read error", e) + return + if buf == b'': + break + length, = struct.unpack('!I', buf) + buf = b'' + while length > 0: + newbuf = await reader.read(length) + if not newbuf: + break + buf += newbuf + length -= len(newbuf) + + result = await self._call_handlers_for_packet(buf) + for r in result: + if r is not None: + if not isinstance(r, list): + r = [r] + msg = osc_message_builder.build_msg(r[0], r[1:]) + b = struct.pack('!I', len(msg.dgram)) + writer.write(b + msg.dgram) + await writer.drain() + + async def handle_1_1(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + LOG.debug("Incoming socket open 1.1") + while True: + try: + buf = await reader.read(4096) + except Exception as e: + LOG.exception("Read error", e) + return + if buf == b'': + break + while len(buf) > 0 and buf[-1] != 192: + newbuf = await reader.read(4096) + if not newbuf: + # Maybe should raise an exception here? + break + buf += newbuf + + packets = [slip.decode(p) for p in buf.split(slip.END_END)] + for p in packets: + result = await self._call_handlers_for_packet(p) + for r in result: + if r is not None: + if not isinstance(r, list): + r = [r] + msg = osc_message_builder.build_msg(r[0], r[1:]) + writer.write(slip.encode(msg.dgram)) + await writer.drain() + + async def _call_handlers_for_packet(self, data) -> list: + """ + This function calls the handlers registered to the dispatcher for + every message it found in the packet. + The process/thread granularity is thus the OSC packet, not the handler. + + If parameters were registered with the dispatcher, then the handlers are + called this way: + handler('/address that triggered the message', + registered_param_list, osc_msg_arg1, osc_msg_arg2, ...) + if no parameters were registered, then it is just called like this: + handler('/address that triggered the message', + osc_msg_arg1, osc_msg_arg2, osc_msg_param3, ...) + """ + + # Get OSC messages from all bundles or standalone message. + results = [] + try: + packet = osc_packet.OscPacket(data) + for timed_msg in packet.messages: + now = time.time() + handlers = self._dispatcher.handlers_for_address( + timed_msg.message.address) + if not handlers: + continue + # If the message is to be handled later, then so be it. + if timed_msg.time > now: + time.sleep(timed_msg.time - now) + for handler in handlers: + if inspect.iscoroutinefunction(handler.callback): + if handler.args: + result = await handler.callback( + timed_msg.message.address, handler.args, *timed_msg.message) + else: + result = await handler.callback(timed_msg.message.address, + *timed_msg.message) + else: + if handler.args: + result = handler.callback( + timed_msg.message.address, handler.args, *timed_msg.message) + else: + result = handler.callback(timed_msg.message.address, *timed_msg.message) + results.append(result) + except osc_packet.ParseError as e: + LOG.debug(f"Packet parse error: {str(e)}") + return results diff --git a/pythonosc/slip.py b/pythonosc/slip.py new file mode 100644 index 0000000..a3d2bcb --- /dev/null +++ b/pythonosc/slip.py @@ -0,0 +1,82 @@ +# Copyright (c) 2020. Ruud de Jong +# This file is part of the SlipLib project which is released under the MIT license. +# See https://github.com/rhjdjong/SlipLib for details. + +import re + +END = b'\xc0' +ESC = b'\xdb' +ESC_END = b'\xdc' +ESC_ESC = b'\xdd' +END_END = b'\xc0\xc0' +"""These constants represent the special SLIP bytes""" + + +class ProtocolError(ValueError): + """Exception to indicate that a SLIP protocol error has occurred. + + This exception is raised when an attempt is made to decode + a packet with an invalid byte sequence. + An invalid byte sequence is either an :const:`ESC` byte followed + by any byte that is not an :const:`ESC_ESC` or :const:`ESC_END` byte, + or a trailing :const:`ESC` byte as last byte of the packet. + + The :exc:`ProtocolError` carries the invalid packet + as the first (and only) element in in its :attr:`args` tuple. + """ + + +def encode(msg: bytes) -> bytes: + """Encodes a message (a byte sequence) into a SLIP-encoded packet. + + Args: + msg: The message that must be encoded + + Returns: + The SLIP-encoded message + """ + if msg: + msg = bytes(msg) + else: + msg = b'' + return END + msg.replace(ESC, ESC + ESC_ESC).replace(END, ESC + ESC_END) + END + + +def decode(packet: bytes) -> bytes: + """Retrieves the message from the SLIP-encoded packet. + + Args: + packet: The SLIP-encoded message. + Note that this must be exactly one complete packet. + The :func:`decode` function does not provide any buffering + for incomplete packages, nor does it provide support + for decoding data with multiple packets. + Returns: + The decoded message + + Raises: + ProtocolError: if the packet contains an invalid byte sequence. + """ + if not is_valid(packet): + raise ProtocolError(packet) + return packet.strip(END).replace(ESC + ESC_END, END).replace(ESC + ESC_ESC, ESC) + + +def is_valid(packet: bytes) -> bool: + """Indicates if the packet's contents conform to the SLIP specification. + + A packet is valid if: + + * It contains no :const:`END` bytes other than leading and/or trailing :const:`END` bytes, and + * Each :const:`ESC` byte is followed by either an :const:`ESC_END` or an :const:`ESC_ESC` byte. + + Args: + packet: The packet to inspect. + + Returns: + :const:`True` if the packet is valid, :const:`False` otherwise + """ + packet = packet.strip(END) + return not (END in packet or + packet.endswith(ESC) or + re.search(ESC + b'[^' + ESC_END + ESC_ESC + b']', packet)) diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py new file mode 100644 index 0000000..e9c0107 --- /dev/null +++ b/pythonosc/tcp_client.py @@ -0,0 +1,213 @@ +"""TCP Clients for sending OSC messages to an OSC server""" +import asyncio +import socket +import struct +from typing import AsyncGenerator, Generator, List, Union + +from pythonosc import slip +from pythonosc.osc_bundle import OscBundle +from pythonosc.osc_message import OscMessage +from pythonosc.osc_message_builder import ArgValue, build_msg +from pythonosc.osc_tcp_server import MODE_1_1 + + +class TCPClient(object): + """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" + + def __init__(self, address: str, port: int, + family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1) -> None: + """Initialize client + + Args: + address: IP address of server + port: Port of server + family: address family parameter (passed to socket.getaddrinfo) + """ + self.address = address + self.port = port + self.family = family + self.mode = mode + self.socket = socket.socket(self.family, socket.SOCK_STREAM) + self.socket.settimeout(30) + self.socket.connect((address, port)) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def send(self, content: Union[OscMessage, OscBundle]) -> None: + """Sends an :class:`OscMessage` or :class:`OscBundle` via TCP + + Args: + content: Message or bundle to be sent + """ + if self.mode == MODE_1_1: + self.socket.sendall(slip.encode(content.dgram)) + else: + b = struct.pack('!I', len(content.dgram)) + self.socket.sendall(b + content.dgram) + + def receive(self, timeout: int = 30) -> List[bytes]: + self.socket.settimeout(timeout) + if self.mode == MODE_1_1: + try: + buf = self.socket.recv(4096) + except TimeoutError: + return [] + if not buf: + return [] + # If the last byte is not an END marker there could be more data coming + while buf[-1] != 192: + try: + newbuf = self.socket.recv(4096) + except TimeoutError: + break + if not newbuf: + # Maybe should raise an exception here? + break + buf += newbuf + return [slip.decode(p) for p in buf.split(slip.END_END)] + else: + buf = b'' + try: + lengthbuf = self.socket.recv(4) + except TimeoutError: + return [] + length, = struct.unpack('!I', lengthbuf) + while length > 0: + try: + newbuf = self.socket.recv(length) + except TimeoutError: + return [] + if not newbuf: + return [] + buf += newbuf + length -= len(newbuf) + return [buf] + + def close(self): + self.socket.close() + + +class SimpleTCPClient(TCPClient): + """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def send_message(self, address: str, value: ArgValue = None) -> None: + """Build :class:`OscMessage` from arguments and send to server + + Args: + address: OSC address the message shall go to + value: One or more arguments to be added to the message + """ + msg = build_msg(address, value) + return self.send(msg) + + def get_messages(self, timeout: int = 30) -> Generator: + r = self.receive(timeout) + while r: + yield OscMessage(r) + r = self.receive(timeout) + + +class AsyncOSCTCPClient: + """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" + + def __init__(self, address: str, port: int, + family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1) -> None: + """Initialize client + + Args: + address: IP address of server + port: Port of server + family: address family parameter (passed to socket.getaddrinfo) + """ + self.reader: asyncio.StreamReader = None + self.writer: asyncio.StreamWriter = None + self.address: str = address + self.port: int = port + self.mode: str = mode + self.family: socket.AddressFamily = family + + def __await__(self): + async def closure(): + await self.__open__() + return self + + return closure().__await__() + + async def __aenter__(self): + await self.__open__() + return self + + async def __open__(self): + self.reader, self.writer = await asyncio.open_connection( + self.address, self.port) + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def send(self, content: Union[OscMessage, OscBundle]) -> None: + """Sends an :class:`OscMessage` or :class:`OscBundle` via TCP + + Args: + content: Message or bundle to be sent + """ + if self.mode == MODE_1_1: + self.writer.write(slip.encode(content.dgram)) + else: + b = struct.pack('!I', len(content.dgram)) + self.writer.write(b + content.dgram) + await self.writer.drain() + + async def receive(self, timeout: int = 30) -> List[bytes]: + try: + async with asyncio.timeout(timeout): + buf = await self.reader.read(4096) + except TimeoutError: + return [] + if not buf: + return [] + # If the last byte is not an END marker there could be more data coming + while buf[-1] != 192: + try: + async with asyncio.timeout(timeout): + newbuf = await self.reader.read(4096) + except asyncio.TimeoutError: + break + if not newbuf: + # Maybe should raise an exception here? + break + buf += newbuf + return [slip.decode(p) for p in buf.split(slip.END_END)] + + async def close(self): + self.writer.write_eof() + self.writer.close() + await self.writer.wait_closed() + + +class AsyncSimpleTCPClient(AsyncOSCTCPClient): + """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" + def __init__(self, address, port, family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1): + super().__init__(address, port, family, mode) + + async def send_message(self, address: str, value: ArgValue = None) -> None: + """Build :class:`OscMessage` from arguments and send to server + + Args: + address: OSC address the message shall go to + value: One or more arguments to be added to the message + """ + msg = build_msg(address, value) + return await self.send(msg) + + async def get_messages(self, timeout: int = 30) -> AsyncGenerator: + r = await self.receive(timeout) + while r: + for m in r: + yield OscMessage(m) + r = await self.receive(timeout) diff --git a/pythonosc/test/test_osc_tcp_server.py b/pythonosc/test/test_osc_tcp_server.py new file mode 100644 index 0000000..2743be8 --- /dev/null +++ b/pythonosc/test/test_osc_tcp_server.py @@ -0,0 +1,275 @@ +import struct +import unittest +import unittest.mock as mock + +from pythonosc import dispatcher, osc_tcp_server +from pythonosc.slip import END + +_SIMPLE_PARAM_INT_MSG = ( + b"/SYNC\x00\x00\x00" + b",i\x00\x00" + b"\x00\x00\x00\x04") + +LEN_SIMPLE_PARAM_INT_MSG = struct.pack('!I', len(_SIMPLE_PARAM_INT_MSG)) +_SIMPLE_PARAM_INT_MSG_1_1 = END + _SIMPLE_PARAM_INT_MSG + END + +# Regression test for a datagram that should NOT be stripped, ever... +_SIMPLE_PARAM_INT_9 = b'/debug\x00\x00,i\x00\x00\x00\x00\x00\t' +LEN_SIMPLE_PARAM_INT_9 = struct.pack('!I', len(_SIMPLE_PARAM_INT_9)) + +_SIMPLE_PARAM_INT_9_1_1 = END + _SIMPLE_PARAM_INT_9 + END + +_SIMPLE_MSG_NO_PARAMS = b"/SYNC\x00\x00\x00" +LEN_SIMPLE_MSG_NO_PARAMS = struct.pack('!I', len(_SIMPLE_MSG_NO_PARAMS)) +_SIMPLE_MSG_NO_PARAMS_1_1 = END + _SIMPLE_MSG_NO_PARAMS + END + + +class TestOscTcpServer(unittest.TestCase): + pass + + +class TestTCP_1_1_Handler(unittest.TestCase): + def setUp(self): + super().setUp() + self.dispatcher = dispatcher.Dispatcher() + # We do not want to create real UDP connections during unit tests. + self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) + # Need to attach property mocks to types, not objects... weird. + type(self.server).dispatcher = unittest.mock.PropertyMock( + return_value=self.dispatcher) + self.client_address = ("127.0.0.1", 8080) + self.mock_meth = unittest.mock.MagicMock() + self.mock_meth.return_value = None + + def test_no_match(self): + self.dispatcher.map("/foobar", self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, _SIMPLE_PARAM_INT_MSG_1_1, ""] + osc_tcp_server._TCPHandler1_1( + mock_sock, self.client_address, self.server) + self.assertFalse(self.mock_meth.called) + + def test_match_with_args(self): + self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, ""] + osc_tcp_server._TCPHandler1_1( + mock_sock, self.client_address, self.server) + self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) + + def test_match_int9(self): + self.dispatcher.map("/debug", self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_9_1_1, ""] + osc_tcp_server._TCPHandler1_1( + mock_sock, self.client_address, self.server) + self.assertTrue(self.mock_meth.called) + self.mock_meth.assert_called_with("/debug", 9) + + def test_match_without_args(self): + self.dispatcher.map("/SYNC", self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] + osc_tcp_server._TCPHandler1_1( + mock_sock, self.client_address, self.server) + self.mock_meth.assert_called_with("/SYNC") + + def test_match_default_handler(self): + self.dispatcher.set_default_handler(self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] + osc_tcp_server._TCPHandler1_1( + mock_sock, self.client_address, self.server) + self.mock_meth.assert_called_with("/SYNC") + + def test_response_no_args(self): + def respond(*args, **kwargs): + return "/SYNC" + self.dispatcher.map("/SYNC", respond) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] + mock_sock.sendall = mock.Mock() + mock_sock.sendall.return_value = None + osc_tcp_server._TCPHandler1_1( + mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with(b'\xc0/SYNC\00\00\00,\00\00\00\xc0') + + def test_response_with_args(self): + def respond(*args, **kwargs): + return ["/SYNC", 1, "2", 3.0,] + self.dispatcher.map("/SYNC", respond) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] + mock_sock.sendall = mock.Mock() + mock_sock.sendall.return_value = None + osc_tcp_server._TCPHandler1_1( + mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with(b'\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0') + + +class TestTCP_1_0_Handler(unittest.TestCase): + def setUp(self): + super().setUp() + self.dispatcher = dispatcher.Dispatcher() + # We do not want to create real UDP connections during unit tests. + self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) + # Need to attach property mocks to types, not objects... weird. + type(self.server).dispatcher = unittest.mock.PropertyMock( + return_value=self.dispatcher) + self.client_address = ("127.0.0.1", 8080) + self.mock_meth = unittest.mock.MagicMock() + self.mock_meth.return_value = None + + def test_no_match(self): + self.dispatcher.map("/foobar", self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, LEN_SIMPLE_PARAM_INT_MSG, + _SIMPLE_PARAM_INT_MSG, ""] + osc_tcp_server._TCPHandler1_0( + mock_sock, self.client_address, self.server) + self.assertFalse(self.mock_meth.called) + + def test_match_with_args(self): + self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [LEN_SIMPLE_PARAM_INT_MSG, _SIMPLE_PARAM_INT_MSG, ""] + osc_tcp_server._TCPHandler1_0( + mock_sock, self.client_address, self.server) + self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) + + def test_match_int9(self): + self.dispatcher.map("/debug", self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [LEN_SIMPLE_PARAM_INT_9, _SIMPLE_PARAM_INT_9, ""] + osc_tcp_server._TCPHandler1_0( + mock_sock, self.client_address, self.server) + self.assertTrue(self.mock_meth.called) + self.mock_meth.assert_called_with("/debug", 9) + + def test_match_without_args(self): + self.dispatcher.map("/SYNC", self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] + osc_tcp_server._TCPHandler1_0( + mock_sock, self.client_address, self.server) + self.mock_meth.assert_called_with("/SYNC") + + def test_match_default_handler(self): + self.dispatcher.set_default_handler(self.mock_meth) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] + osc_tcp_server._TCPHandler1_0( + mock_sock, self.client_address, self.server) + self.mock_meth.assert_called_with("/SYNC") + + def test_response_no_args(self): + def respond(*args, **kwargs): + return "/SYNC" + self.dispatcher.map("/SYNC", respond) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] + mock_sock.sendall = mock.Mock() + mock_sock.sendall.return_value = None + osc_tcp_server._TCPHandler1_0( + mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with(b'\x00\x00\x00\x0c/SYNC\00\00\00,\00\00\00') + + def test_response_with_args(self): + def respond(*args, **kwargs): + return ["/SYNC", 1, "2", 3.0,] + self.dispatcher.map("/SYNC", respond) + mock_sock = mock.Mock() + mock_sock.recv = mock.Mock() + mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] + mock_sock.sendall = mock.Mock() + mock_sock.sendall.return_value = None + osc_tcp_server._TCPHandler1_0( + mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with(b'\x00\x00\x00\x1c/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00') + + +class TestAsync1_1Handler(unittest.IsolatedAsyncioTestCase): + def setUp(self): + super().setUp() + self.dispatcher = dispatcher.Dispatcher() + # We do not want to create real UDP connections during unit tests. + self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) + # Need to attach property mocks to types, not objects... weird. + type(self.server).dispatcher = unittest.mock.PropertyMock( + return_value=self.dispatcher) + self.client_address = ("127.0.0.1", 8080) + self.mock_writer = mock.Mock() + self.mock_writer.close = mock.Mock() + self.mock_writer.write = mock.Mock() + self.mock_writer.write_eof = mock.Mock() + self.mock_writer.drain = mock.AsyncMock() + self.mock_writer.wait_closed = mock.AsyncMock() + self.mock_reader = mock.Mock() + self.mock_reader.read = mock.AsyncMock() + self.server = osc_tcp_server.AsyncOSCTCPServer("127.0.0.1", 8008, self.dispatcher) + self.mock_meth = unittest.mock.MagicMock() + self.mock_meth.return_value = None + + async def test_no_match(self): + self.dispatcher.map("/foobar", self.mock_meth) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, _SIMPLE_PARAM_INT_MSG_1_1, b''] + await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.assertFalse(self.mock_meth.called) + + async def test_match_with_args(self): + self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) + self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, b''] + await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) + + async def test_match_int9(self): + self.dispatcher.map("/debug", self.mock_meth) + self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_9_1_1, b''] + await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.assertTrue(self.mock_meth.called) + self.mock_meth.assert_called_with("/debug", 9) + + async def test_match_without_args(self): + self.dispatcher.map("/SYNC", self.mock_meth) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] + await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_meth.assert_called_with("/SYNC") + + async def test_match_default_handler(self): + self.dispatcher.set_default_handler(self.mock_meth) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] + await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_meth.assert_called_with("/SYNC") + + async def test_response_no_args(self): + def respond(*args, **kwargs): + return "/SYNC" + self.dispatcher.map("/SYNC", respond) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] + await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_writer.write.assert_called_with(b'\xc0/SYNC\00\00\00,\00\00\00\xc0') + + async def test_response_with_args(self): + def respond(*args, **kwargs): + return ["/SYNC", 1, "2", 3.0,] + self.dispatcher.map("/SYNC", respond) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] + await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_writer.write.assert_called_with(b'\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0') + + +if __name__ == "__main__": + unittest.main() diff --git a/pythonosc/test/test_tcp_client.py b/pythonosc/test/test_tcp_client.py new file mode 100644 index 0000000..37990e4 --- /dev/null +++ b/pythonosc/test/test_tcp_client.py @@ -0,0 +1,69 @@ +import asyncio +import unittest +from unittest import mock + +from pythonosc import osc_message_builder, slip, tcp_client + + +class TestTcpClient(unittest.TestCase): + @mock.patch('socket.socket') + def test_client(self, mock_socket_ctor): + mock_socket = mock_socket_ctor.return_value + mock_send = mock.Mock() + mock_recv = mock.Mock() + mock_send.return_value = None + mock_recv.return_value = "" + + mock_socket.sendall = mock_send + mock_socket.recv = mock_recv + msg = osc_message_builder.OscMessageBuilder('/').build() + with tcp_client.TCPClient('::1', 31337) as client: + client.send(msg) + mock_socket.sendall.assert_called_once_with(slip.encode(msg.dgram)) + + @mock.patch('socket.socket') + def test_simple_client(self, mock_socket_ctor): + mock_socket = mock_socket_ctor.return_value + mock_send = mock.Mock() + mock_recv = mock.Mock() + mock_send.return_value = None + mock_recv.return_value = "" + + mock_socket.sendall = mock_send + mock_socket.recv = mock_recv + with tcp_client.SimpleTCPClient('::1', 31337) as client: + client.send_message('/', []) + mock_socket.sendall.assert_called_once() + + +class TestAsyncTcpClient(unittest.IsolatedAsyncioTestCase): + @mock.patch('asyncio.open_connection') + async def test_send(self, mock_socket_ctor): + mock_reader = mock.Mock() + mock_writer = mock.Mock() + mock_writer.drain = mock.AsyncMock() + mock_writer.wait_closed = mock.AsyncMock() + mock_socket_ctor.return_value = (mock_reader, mock_writer) + loop = asyncio.get_running_loop() + loop.set_debug(False) + msg = osc_message_builder.OscMessageBuilder('/').build() + async with tcp_client.AsyncOSCTCPClient('::1', 31337) as client: + await client.send(msg) + + self.assertTrue(mock_writer.write.called) + mock_writer.write.assert_called_once_with(slip.encode(msg.dgram)) + + @mock.patch('asyncio.open_connection') + async def test_send_message_calls_send_with_msg(self, mock_socket_ctor): + mock_reader = mock.Mock() + mock_writer = mock.Mock() + mock_writer.drain = mock.AsyncMock() + mock_writer.wait_closed = mock.AsyncMock() + mock_socket_ctor.return_value = (mock_reader, mock_writer) + async with tcp_client.AsyncSimpleTCPClient('::1', 31337) as client: + await client.send_message('/address', 1) + self.assertTrue(mock_writer.write.called) + + +if __name__ == "__main__": + unittest.main() From 7b5d8ea33741f1dfbc9b548e1846f503e52ba7fd Mon Sep 17 00:00:00 2001 From: Bob Haddleton Date: Sat, 3 Aug 2024 10:43:49 -0400 Subject: [PATCH 21/69] cleanup mypy errors, address review comments, add response support to UDP server --- CHANGELOG.md | 3 + docs/client.rst | 26 ++- docs/dispatcher.rst | 12 ++ docs/server.rst | 7 +- examples/async_simple_tcp_client.py | 21 ++- examples/async_tcp_server.py | 19 +- examples/simple_echo_client.py | 27 +++ examples/simple_echo_server.py | 30 +++ examples/simple_tcp_client.py | 16 +- examples/simple_tcp_server.py | 17 +- pythonosc/dispatcher.py | 116 ++++++++++-- pythonosc/osc_message_builder.py | 12 +- pythonosc/osc_server.py | 36 +++- pythonosc/osc_tcp_server.py | 262 ++++++++++---------------- pythonosc/slip.py | 43 ++++- pythonosc/tcp_client.py | 128 +++++++++---- pythonosc/test/test_osc_server.py | 43 ++++- pythonosc/test/test_osc_tcp_server.py | 242 ++++++++++++++++-------- pythonosc/test/test_tcp_client.py | 24 +-- pythonosc/udp_client.py | 50 ++++- 20 files changed, 767 insertions(+), 367 deletions(-) create mode 100644 examples/simple_echo_client.py create mode 100644 examples/simple_echo_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b34d55..34581d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +- Added TCP Client and Server support for OSC 1.0 and OSC 1.1 formats, with support for sending responses to the client +- Added response support to the existing UDP Client and Server code + ## [1.8.3] - Using trusted publisher setup to publish to pypi diff --git a/docs/client.rst b/docs/client.rst index 0bbf142..2db133d 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -1,9 +1,14 @@ Client ======== -The client allows you to connect and send messages to an OSC server. The client class expects an :class:`OSCMessage` object, which is then sent out via UDP. Additionally, a simple client class exists that constructs the :class:`OSCMessage` object for you. +The client allows you to connect and exchange messages with an OSC server. +Client classes are available for UDP and TCP protocols. +The base client class ``send`` method expects an :class:`OSCMessage` object, which is then sent out over TCP or UDP. +Additionally, a simple client class exists that constructs the :class:`OSCMessage` object for you. -Example +See the examples folder for more use cases. + +Examples --------- .. code-block:: python @@ -19,6 +24,18 @@ Example client.send_message("/some/address", [1, 2., "hello"]) # Send message with int, float and string +.. code-block:: python + + from pythonosc.tcp_client import SimpleTCPClient + + ip = "127.0.0.1" + port = 1337 + + client = SimpleTCPClient(ip, port) # Create client + + client.send_message("/some/address", 123) # Send float message + client.send_message("/some/address", [1, 2., "hello"]) # Send message with int, float and string + Client Module Documentation --------------------------------- @@ -26,3 +43,8 @@ Client Module Documentation :special-members: :members: :exclude-members: __weakref__ + +.. automodule:: pythonosc.tcp_client + :special-members: + :members: + :exclude-members: __weakref__ diff --git a/docs/dispatcher.rst b/docs/dispatcher.rst index 8e20c3e..66e5b44 100644 --- a/docs/dispatcher.rst +++ b/docs/dispatcher.rst @@ -117,6 +117,18 @@ The handler must have the same signature as map callbacks: def some_callback(address: str, *osc_arguments: List[Any]) -> None: +Handler Responses +----------------- + +Handler functions can return responses back to the client, when running on a server, or to the +server when running as a client. Handler functions should return one of: + +* None +* An OSC address in string format +* A tuple containing a string OSC address and the associated arguments + +If the handler function response is not None it will be encoded in an OSCMessage and sent to the +remote client or server. Dispatcher Module Documentation --------------------------------- diff --git a/docs/server.rst b/docs/server.rst index c73bd83..b71eda6 100644 --- a/docs/server.rst +++ b/docs/server.rst @@ -2,7 +2,7 @@ Server ========= The server receives OSC Messages from connected clients and invoked the appropriate callback functions with the dispatcher. There are several server types available. - +Server implementations are available for both UDP and TCP protocols. Blocking Server ----------------- @@ -123,6 +123,11 @@ Server Module Documentation ------------------------------ .. automodule:: pythonosc.osc_server + :special-members: + :members: + :exclude-members: __weakref__ + +.. automodule:: pythonosc.osc_tcp_server :special-members: :members: :exclude-members: __weakref__ \ No newline at end of file diff --git a/examples/async_simple_tcp_client.py b/examples/async_simple_tcp_client.py index b5297c7..cdadbd3 100644 --- a/examples/async_simple_tcp_client.py +++ b/examples/async_simple_tcp_client.py @@ -4,6 +4,7 @@ sends 10 random values between 0.0 and 1.0 to the /filter address, waiting for 1 seconds between each value in a second task. """ + import argparse import asyncio import random @@ -27,19 +28,25 @@ async def send_messages(client): async def init_main(): parser = argparse.ArgumentParser() - parser.add_argument("--ip", default="127.0.0.1", - help="The ip of the OSC server") - parser.add_argument("--port", type=int, default=5005, - help="The port the OSC server is listening on") - parser.add_argument("--mode", default="1.1", - help="The OSC protocol version of the server (default is 1.1)") + parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") + parser.add_argument( + "--port", type=int, default=5005, help="The port the OSC server is listening on" + ) + parser.add_argument( + "--mode", + default="1.1", + help="The OSC protocol version of the server (default is 1.1)", + ) args = parser.parse_args() - async with tcp_client.AsyncSimpleTCPClient(args.ip, args.port, mode=args.mode) as client: + async with tcp_client.AsyncSimpleTCPClient( + args.ip, args.port, mode=args.mode + ) as client: async with asyncio.TaskGroup() as tg: tg.create_task(get_messages(client)) tg.create_task(send_messages(client)) + if sys.version_info >= (3, 7): asyncio.run(init_main()) else: diff --git a/examples/async_tcp_server.py b/examples/async_tcp_server.py index 873ac3d..532ff79 100644 --- a/examples/async_tcp_server.py +++ b/examples/async_tcp_server.py @@ -23,15 +23,20 @@ async def loop(): async def init_main(): parser = argparse.ArgumentParser() - parser.add_argument("--ip", default="127.0.0.1", - help="The ip of the OSC server") - parser.add_argument("--port", type=int, default=5005, - help="The port the OSC server is listening on") - parser.add_argument("--mode", default="1.1", - help="The OSC protocol version of the server (default is 1.1)") + parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") + parser.add_argument( + "--port", type=int, default=5005, help="The port the OSC server is listening on" + ) + parser.add_argument( + "--mode", + default="1.1", + help="The OSC protocol version of the server (default is 1.1)", + ) args = parser.parse_args() - async with AsyncOSCTCPServer(args.ip, args.port, dispatcher, mode=args.mode) as server: + async with AsyncOSCTCPServer( + args.ip, args.port, dispatcher, mode=args.mode + ) as server: async with asyncio.TaskGroup() as tg: tg.create_task(server.start()) tg.create_task(loop()) diff --git a/examples/simple_echo_client.py b/examples/simple_echo_client.py new file mode 100644 index 0000000..f98ad09 --- /dev/null +++ b/examples/simple_echo_client.py @@ -0,0 +1,27 @@ +"""Small example OSC client + +This program sends 10 random values between 0.0 and 1.0 to the /filter address, +waiting for 1 seconds between each value. +""" + +import argparse +import random +import time + +from pythonosc import udp_client + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") + parser.add_argument( + "--port", type=int, default=5005, help="The port the OSC server is listening on" + ) + args = parser.parse_args() + + client = udp_client.SimpleUDPClient(args.ip, args.port) + + for x in range(10): + client.send_message("/filter", random.random()) + reply = next(client.get_messages(2)) + print(str(reply)) + time.sleep(1) diff --git a/examples/simple_echo_server.py b/examples/simple_echo_server.py new file mode 100644 index 0000000..9347326 --- /dev/null +++ b/examples/simple_echo_server.py @@ -0,0 +1,30 @@ +"""Small example OSC server + +This program listens to several addresses, and prints some information about +received packets. +""" + +import argparse +import math + +from pythonosc.dispatcher import Dispatcher +from pythonosc import osc_server + + +def echo_handler(client_addr, unused_addr, args): + print(unused_addr, args) + return (unused_addr, args) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") + parser.add_argument("--port", type=int, default=5005, help="The port to listen on") + args = parser.parse_args() + + dispatcher = Dispatcher() + dispatcher.set_default_handler(echo_handler, True) + + server = osc_server.ThreadingOSCUDPServer((args.ip, args.port), dispatcher) + print("Serving on {}".format(server.server_address)) + server.serve_forever() diff --git a/examples/simple_tcp_client.py b/examples/simple_tcp_client.py index 53d319c..0834742 100644 --- a/examples/simple_tcp_client.py +++ b/examples/simple_tcp_client.py @@ -3,6 +3,7 @@ This program sends 10 random values between 0.0 and 1.0 to the /filter address, and listens for incoming messages for 1 second between each value. """ + import argparse import random @@ -10,12 +11,15 @@ if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--ip", default="127.0.0.1", - help="The ip of the OSC server") - parser.add_argument("--port", type=int, default=5005, - help="The port the OSC server is listening on") - parser.add_argument("--mode", default="1.1", - help="The OSC protocol version of the server (default is 1.1)") + parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") + parser.add_argument( + "--port", type=int, default=5005, help="The port the OSC server is listening on" + ) + parser.add_argument( + "--mode", + default="1.1", + help="The OSC protocol version of the server (default is 1.1)", + ) args = parser.parse_args() with tcp_client.SimpleTCPClient(args.ip, args.port, mode=args.mode) as client: diff --git a/examples/simple_tcp_server.py b/examples/simple_tcp_server.py index c5883ea..13c9a50 100644 --- a/examples/simple_tcp_server.py +++ b/examples/simple_tcp_server.py @@ -3,6 +3,7 @@ This program listens to the specified address and port, and prints some information about received packets. """ + import argparse import math @@ -23,12 +24,13 @@ def print_compute_handler(unused_addr, args, volume): if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--ip", - default="127.0.0.1", help="The ip to listen on") - parser.add_argument("--port", - type=int, default=5005, help="The port to listen on") - parser.add_argument("--mode", default="1.1", - help="The OSC protocol version of the server (default is 1.1)") + parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") + parser.add_argument("--port", type=int, default=5005, help="The port to listen on") + parser.add_argument( + "--mode", + default="1.1", + help="The OSC protocol version of the server (default is 1.1)", + ) args = parser.parse_args() @@ -38,6 +40,7 @@ def print_compute_handler(unused_addr, args, volume): dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) server = osc_tcp_server.ThreadingOSCTCPServer( - (args.ip, args.port), dispatcher, mode=args.mode) + (args.ip, args.port), dispatcher, mode=args.mode + ) print("Serving on {}".format(server.server_address)) server.serve_forever() diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index 462a0c1..7f99c0d 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -2,6 +2,7 @@ """ import collections +import inspect import logging import re import time @@ -11,6 +12,7 @@ List, Union, Any, + AnyStr, Generator, Tuple, Callable, @@ -18,6 +20,7 @@ DefaultDict, ) from pythonosc.osc_message import OscMessage +from pythonosc.osc_message_builder import ArgValue class Handler(object): @@ -53,23 +56,30 @@ def __eq__(self, other: Any) -> bool: and self.needs_reply_address == other.needs_reply_address ) - def invoke(self, client_address: Tuple[str, int], message: OscMessage) -> None: + def invoke( + self, client_address: Tuple[str, int], message: OscMessage + ) -> Union[None, AnyStr, Tuple[AnyStr, ArgValue]]: """Invokes the associated callback function Args: client_address: Address match that causes the invocation message: Message causing invocation + Returns: + The result of the handler function can be None, a string OSC address, or a tuple of the OSC address + and arguments. """ if self.needs_reply_address: if self.args: - self.callback(client_address, message.address, self.args, *message) + return self.callback( + client_address, message.address, self.args, *message + ) else: - self.callback(client_address, message.address, *message) + return self.callback(client_address, message.address, *message) else: if self.args: - self.callback(message.address, self.args, *message) + return self.callback(message.address, self.args, *message) else: - self.callback(message.address, *message) + return self.callback(message.address, *message) class Dispatcher(object): @@ -93,11 +103,17 @@ def map( The callback function must have one of the following signatures: - ``def some_cb(address: str, *osc_args: List[Any]) -> None:`` - ``def some_cb(address: str, fixed_args: List[Any], *osc_args: List[Any]) -> None:`` + ``def some_cb(address: str, *osc_args: List[Any]) -> Union[None, AnyStr, Tuple(str, ArgValue)]:`` + ``def some_cb(address: str, fixed_args: List[Any], *osc_args: List[Any]) -> Union[None, AnyStr, + Tuple(str, ArgValue)]:`` + + ``def some_cb(client_address: Tuple[str, int], address: str, *osc_args: List[Any]) -> Union[None, AnyStr, + Tuple(str, ArgValue)]:`` + ``def some_cb(client_address: Tuple[str, int], address: str, fixed_args: List[Any], *osc_args: List[Any]) -> Union[None, AnyStr, Tuple(str, ArgValue)]:`` - ``def some_cb(client_address: Tuple[str, int], address: str, *osc_args: List[Any]) -> None:`` - ``def some_cb(client_address: Tuple[str, int], address: str, fixed_args: List[Any], *osc_args: List[Any]) -> None:`` + The callback function can return None, or a string representing an OSC address to be returned to the client, + or a tuple that includes the address and ArgValue which will be converted to an OSC message and returned to + the client. Args: address: Address to be mapped @@ -204,7 +220,7 @@ def handlers_for_address( def call_handlers_for_packet( self, data: bytes, client_address: Tuple[str, int] - ) -> None: + ) -> List: """Invoke handlers for all messages in OSC packet The incoming OSC Packet is decoded and the handlers for each included message is found and invoked. @@ -212,8 +228,9 @@ def call_handlers_for_packet( Args: data: Data of packet client_address: Address of client this packet originated from + Returns: A list of strings or tuples to be converted to OSC messages and returned to the client """ - + results = list() # Get OSC messages from all bundles or standalone message. try: packet = osc_packet.OscPacket(data) @@ -226,9 +243,84 @@ def call_handlers_for_packet( if timed_msg.time > now: time.sleep(timed_msg.time - now) for handler in handlers: - handler.invoke(client_address, timed_msg.message) + result = handler.invoke(client_address, timed_msg.message) + if result is not None: + results.append(result) except osc_packet.ParseError: pass + return results + + async def async_call_handlers_for_packet( + self, data: bytes, client_address: Tuple[str, int] + ) -> List: + """ + This function calls the handlers registered to the dispatcher for + every message it found in the packet. + The process/thread granularity is thus the OSC packet, not the handler. + + If parameters were registered with the dispatcher, then the handlers are + called this way: + handler('/address that triggered the message', + registered_param_list, osc_msg_arg1, osc_msg_arg2, ...) + if no parameters were registered, then it is just called like this: + handler('/address that triggered the message', + osc_msg_arg1, osc_msg_arg2, osc_msg_param3, ...) + """ + + # Get OSC messages from all bundles or standalone message. + results = [] + try: + packet = osc_packet.OscPacket(data) + for timed_msg in packet.messages: + now = time.time() + handlers = self.handlers_for_address(timed_msg.message.address) + if not handlers: + continue + # If the message is to be handled later, then so be it. + if timed_msg.time > now: + time.sleep(timed_msg.time - now) + for handler in handlers: + if inspect.iscoroutinefunction(handler.callback): + if handler.needs_reply_address: + result = await handler.callback( + client_address, + timed_msg.message.address, + handler.args, + *timed_msg.message, + ) + elif handler.args: + result = await handler.callback( + timed_msg.message.address, + handler.args, + *timed_msg.message, + ) + else: + result = await handler.callback( + timed_msg.message.address, *timed_msg.message + ) + else: + if handler.needs_reply_address: + result = handler.callback( + client_address, + timed_msg.message.address, + handler.args, + *timed_msg.message, + ) + elif handler.args: + result = handler.callback( + timed_msg.message.address, + handler.args, + *timed_msg.message, + ) + else: + result = handler.callback( + timed_msg.message.address, *timed_msg.message + ) + if result: + results.append(result) + except osc_packet.ParseError as e: + pass + return results def set_default_handler( self, handler: Callable, needs_reply_address: bool = False diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index ffbb1e5..b51f2b8 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -1,11 +1,10 @@ """Build OSC messages for client applications.""" -from typing import Iterable + +from typing import Any, Iterable, List, Optional, Tuple, Union from pythonosc import osc_message from pythonosc.parsing import osc_types -from typing import List, Tuple, Union, Any, Optional - ArgValue = Union[str, bytes, bool, int, float, osc_types.MidiPacket, list] @@ -198,9 +197,10 @@ def build(self) -> osc_message.OscMessage: raise BuildError("Could not build the message: {}".format(be)) -def build_msg(address: str, value: ArgValue): +def build_msg(address: str, value: ArgValue = "") -> osc_message.OscMessage: builder = OscMessageBuilder(address=address) - if value is None: + values: ArgValue + if value == "": values = [] elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): values = [value] @@ -208,4 +208,4 @@ def build_msg(address: str, value: ArgValue): values = value for val in values: builder.add_arg(val) - return builder.build() \ No newline at end of file + return builder.build() diff --git a/pythonosc/osc_server.py b/pythonosc/osc_server.py index 7de729e..b2fd5a9 100644 --- a/pythonosc/osc_server.py +++ b/pythonosc/osc_server.py @@ -4,15 +4,12 @@ import asyncio import os import socketserver +from socket import socket as _socket +from typing import Any, Coroutine, Tuple, Union, cast -from pythonosc import osc_bundle -from pythonosc import osc_message +from pythonosc import osc_bundle, osc_message from pythonosc.dispatcher import Dispatcher - -from asyncio import BaseEventLoop - -from socket import socket as _socket -from typing import Any, Tuple, Union, cast, Coroutine +from pythonosc.osc_message_builder import build_msg _RequestType = Union[_socket, Tuple[bytes, _socket]] _AddressType = Union[Tuple[str, int], str] @@ -21,6 +18,10 @@ class _UDPHandler(socketserver.BaseRequestHandler): """Handles correct UDP messages for all types of server.""" + def __init__(self, request, client_address, server): + self.socket = request[1] + super().__init__(request, client_address, server) + def handle(self) -> None: """Calls the handlers via dispatcher @@ -30,7 +31,14 @@ def handle(self) -> None: threads/processes will be spawned. """ server = cast(OSCUDPServer, self.server) - server.dispatcher.call_handlers_for_packet(self.request[0], self.client_address) + resp = server.dispatcher.call_handlers_for_packet( + self.request[0], self.client_address + ) + for r in resp: + if not isinstance(r, tuple): + r = [r] + msg = build_msg(r[0], r[1:]) + self.socket.sendto(msg.dgram, self.client_address) def _is_valid_request(request: _RequestType) -> bool: @@ -124,7 +132,7 @@ def __init__( self, server_address: Tuple[str, int], dispatcher: Dispatcher, - loop: BaseEventLoop, + loop: asyncio.BaseEventLoop, ) -> None: """Initialize @@ -145,10 +153,18 @@ class _OSCProtocolFactory(asyncio.DatagramProtocol): def __init__(self, dispatcher: Dispatcher) -> None: self.dispatcher = dispatcher + def connection_made(self, transport): + self.transport = transport + def datagram_received( self, data: bytes, client_address: Tuple[str, int] ) -> None: - self.dispatcher.call_handlers_for_packet(data, client_address) + resp = self.dispatcher.call_handlers_for_packet(data, client_address) + for r in resp: + if not isinstance(r, tuple): + r = [r] + msg = build_msg(r[0], r[1:]) + self.transport.sendto(msg.dgram, client_address) def serve(self) -> None: """Creates a datagram endpoint and registers it with event loop. diff --git a/pythonosc/osc_tcp_server.py b/pythonosc/osc_tcp_server.py index c648638..56e0816 100644 --- a/pythonosc/osc_tcp_server.py +++ b/pythonosc/osc_tcp_server.py @@ -1,6 +1,3 @@ -# TODO: timeouts! - - """OSC Servers that receive TCP packets and invoke handlers accordingly. Use like this: @@ -33,63 +30,23 @@ """ +# mypy: disable-error-code="attr-defined" + import asyncio -import inspect import logging import os import socketserver import struct -import time +from typing import List, Tuple -from pythonosc import osc_message_builder, osc_packet, slip +from pythonosc import osc_message_builder, slip +from pythonosc.dispatcher import Dispatcher LOG = logging.getLogger() MODE_1_0 = "1.0" MODE_1_1 = "1.1" -def _call_handlers_for_packet(data, dispatcher): - """ - This function calls the handlers registered to the dispatcher for - every message it found in the packet. - The process/thread granularity is thus the OSC packet, not the handler. - - If parameters were registered with the dispatcher, then the handlers are - called this way: - handler('/address that triggered the message', - registered_param_list, osc_msg_arg1, osc_msg_arg2, ...) - if no parameters were registered, then it is just called like this: - handler('/address that triggered the message', - osc_msg_arg1, osc_msg_arg2, osc_msg_param3, ...) - """ - - # Get OSC messages from all bundles or standalone message. - all_resp = [] - try: - LOG.debug("_call_handlers_for_packet: data ", data) - packet = osc_packet.OscPacket(data) - for timed_msg in packet.messages: - now = time.time() - handlers = dispatcher.handlers_for_address( - timed_msg.message.address) - if not handlers: - continue - # If the message is to be handled later, then so be it. - if timed_msg.time > now: - time.sleep(timed_msg.time - now) - for handler in handlers: - if handler.args: - resp = handler.callback( - timed_msg.message.address, handler.args, *timed_msg.message) - else: - resp = handler.callback(timed_msg.message.address, *timed_msg.message) - if resp: - all_resp.append(resp) - except osc_packet.ParseError: - pass - return all_resp - - class _TCPHandler1_0(socketserver.BaseRequestHandler): """Handles correct OSC1.0 messages. @@ -101,32 +58,35 @@ class _TCPHandler1_0(socketserver.BaseRequestHandler): if not the server won't even bother to call it and so no new threads/processes will be spawned. """ - def handle(self): + + def handle(self) -> None: LOG.debug("handle OSC 1.0 protocol") while True: lengthbuf = self.recvall(4) - if lengthbuf is None: + if lengthbuf == b"": break - length, = struct.unpack('!I', lengthbuf) + (length,) = struct.unpack("!I", lengthbuf) data = self.recvall(length) - if data is None: + if data == b"": break - resp = _call_handlers_for_packet(data, self.server.dispatcher) + resp = self.server.dispatcher.call_handlers_for_packet( + data, self.client_address + ) + # resp = _call_handlers_for_packet(data, self.server.dispatcher) for r in resp: - if r is not None: - if not isinstance(r, list): - r = [r] - msg = osc_message_builder.build_msg(r[0], r[1:]) - b = struct.pack('!I', len(msg.dgram)) - self.request.sendall(b + msg.dgram) - - def recvall(self, count): - buf = b'' + if not isinstance(r, list): + r = [r] + msg = osc_message_builder.build_msg(r[0], r[1:]) + b = struct.pack("!I", len(msg.dgram)) + self.request.sendall(b + msg.dgram) + + def recvall(self, count: int) -> bytes: + buf = b"" while count > 0: newbuf = self.request.recv(count) if not newbuf: - return None + return b"" buf += newbuf count -= len(newbuf) return buf @@ -143,25 +103,29 @@ class _TCPHandler1_1(socketserver.BaseRequestHandler): if not the server won't even bother to call it and so no new threads/processes will be spawned. """ - def handle(self): + + def handle(self) -> None: LOG.debug("handle OSC 1.1 protocol") while True: packets = self.recvall() - if packets is None: + if not packets: break for p in packets: - resp = _call_handlers_for_packet(p, self.server.dispatcher) + # resp = _call_handlers_for_packet(p, self.server.dispatcher) + resp = self.server.dispatcher.call_handlers_for_packet( + p, self.client_address + ) for r in resp: if not isinstance(r, list): r = [r] msg = osc_message_builder.build_msg(r[0], r[1:]) self.request.sendall(slip.encode(msg.dgram)) - def recvall(self): + def recvall(self) -> List[bytes]: buf = self.request.recv(4096) if not buf: - return None + return [] # If the last byte is not an END marker there could be more data coming while buf[-1] != 192: newbuf = self.request.recv(4096) @@ -177,7 +141,12 @@ def recvall(self): class OSCTCPServer(socketserver.TCPServer): """Superclass for different flavors of OSCTCPServer""" - def __init__(self, server_address, dispatcher, mode: str = MODE_1_1): + def __init__( + self, + server_address: Tuple[str | bytes | bytearray, int], + dispatcher: Dispatcher, + mode: str = MODE_1_1, + ): self.request_queue_size = 300 self.mode = mode if mode not in [MODE_1_0, MODE_1_1]: @@ -188,12 +157,6 @@ def __init__(self, server_address, dispatcher, mode: str = MODE_1_1): super().__init__(server_address, _TCPHandler1_1) self._dispatcher = dispatcher - def verify_request(self, request, client_address): - """Returns true if the data looks like a valid OSC TCP datagram.""" - # d = request.recv(9999).decode("utf-8") - # print("d:type=%s d=%s" % (type(d), d)) - return True - @property def dispatcher(self): """Dispatcher accessor for handlers to dispatch osc messages.""" @@ -218,6 +181,7 @@ class ThreadingOSCTCPServer(socketserver.ThreadingMixIn, OSCTCPServer): if hasattr(os, "fork"): + class ForkingOSCTCPServer(socketserver.ForkingMixIn, OSCTCPServer): """Forking version of the OSC TCP server. @@ -233,7 +197,13 @@ class AsyncOSCTCPServer: OSCTCPServer family of blocking, threading, and forking servers """ - def __init__(self, server_address: str, port: int, dispatcher, mode: str = MODE_1_1): + def __init__( + self, + server_address: str, + port: int, + dispatcher: Dispatcher, + mode: str = MODE_1_1, + ): """ :param server_address: tuple of (IP address to bind to, port) :param dispatcher: a pythonosc.dispatcher.Dispatcher @@ -241,53 +211,57 @@ def __init__(self, server_address: str, port: int, dispatcher, mode: str = MODE_ self._port = port self._server_address = server_address self._dispatcher = dispatcher - self._server = None self._mode = mode - # class _OSCProtocolFactory(asyncio.DatagramProtocol): - # """OSC protocol factory which passes datagrams to _call_handlers_for_packet""" - # - # def __init__(self, dispatcher): - # self.dispatcher = dispatcher - # - # def datagram_received(self, data, unused_addr): - # _call_handlers_for_packet(data, self.dispatcher) - async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): await self.stop() - async def start(self): + async def start(self) -> None: """creates a socket endpoint and registers it with our event loop""" self._server = await asyncio.start_server( - self.handle, self._server_address, self._port) + self.handle, self._server_address, self._port + ) - addrs = ', '.join(str(sock.getsockname()) for sock in self._server.sockets) - LOG.debug(f'Serving on {addrs}') + addrs = ", ".join(str(sock.getsockname()) for sock in self._server.sockets) + LOG.debug(f"Serving on {addrs}") async with self._server: await self._server.serve_forever() - async def stop(self): - await self._server.cancel() + async def stop(self) -> None: + self._server.close() + await self._server.wait_closed() @property def dispatcher(self): return self._dispatcher - async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + async def handle( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + client_address = ("", 0) + sock = writer.transport.get_extra_info("socket") + if sock is not None: + client_address = sock.getpeername() + if self._mode == MODE_1_1: - await self.handle_1_1(reader, writer) + await self.handle_1_1(reader, writer, client_address) else: - await self.handle1_0(reader, writer) + await self.handle1_0(reader, writer, client_address) writer.write_eof() LOG.debug("Close the connection") writer.close() await writer.wait_closed() - async def handle1_0(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + async def handle1_0( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + client_address: Tuple[str, int], + ) -> None: LOG.debug("Incoming socket open 1.0") while True: try: @@ -295,10 +269,10 @@ async def handle1_0(self, reader: asyncio.StreamReader, writer: asyncio.StreamWr except Exception as e: LOG.exception("Read error", e) return - if buf == b'': + if buf == b"": break - length, = struct.unpack('!I', buf) - buf = b'' + (length,) = struct.unpack("!I", buf) + buf = b"" while length > 0: newbuf = await reader.read(length) if not newbuf: @@ -306,17 +280,23 @@ async def handle1_0(self, reader: asyncio.StreamReader, writer: asyncio.StreamWr buf += newbuf length -= len(newbuf) - result = await self._call_handlers_for_packet(buf) + result = await self.dispatcher.async_call_handlers_for_packet( + buf, client_address + ) for r in result: - if r is not None: - if not isinstance(r, list): - r = [r] - msg = osc_message_builder.build_msg(r[0], r[1:]) - b = struct.pack('!I', len(msg.dgram)) - writer.write(b + msg.dgram) - await writer.drain() - - async def handle_1_1(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + if not isinstance(r, list): + r = [r] + msg = osc_message_builder.build_msg(r[0], r[1:]) + b = struct.pack("!I", len(msg.dgram)) + writer.write(b + msg.dgram) + await writer.drain() + + async def handle_1_1( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + client_address: Tuple[str, int], + ) -> None: LOG.debug("Incoming socket open 1.1") while True: try: @@ -324,7 +304,7 @@ async def handle_1_1(self, reader: asyncio.StreamReader, writer: asyncio.StreamW except Exception as e: LOG.exception("Read error", e) return - if buf == b'': + if buf == b"": break while len(buf) > 0 and buf[-1] != 192: newbuf = await reader.read(4096) @@ -335,58 +315,12 @@ async def handle_1_1(self, reader: asyncio.StreamReader, writer: asyncio.StreamW packets = [slip.decode(p) for p in buf.split(slip.END_END)] for p in packets: - result = await self._call_handlers_for_packet(p) + result = await self.dispatcher.async_call_handlers_for_packet( + p, client_address + ) for r in result: - if r is not None: - if not isinstance(r, list): - r = [r] - msg = osc_message_builder.build_msg(r[0], r[1:]) - writer.write(slip.encode(msg.dgram)) - await writer.drain() - - async def _call_handlers_for_packet(self, data) -> list: - """ - This function calls the handlers registered to the dispatcher for - every message it found in the packet. - The process/thread granularity is thus the OSC packet, not the handler. - - If parameters were registered with the dispatcher, then the handlers are - called this way: - handler('/address that triggered the message', - registered_param_list, osc_msg_arg1, osc_msg_arg2, ...) - if no parameters were registered, then it is just called like this: - handler('/address that triggered the message', - osc_msg_arg1, osc_msg_arg2, osc_msg_param3, ...) - """ - - # Get OSC messages from all bundles or standalone message. - results = [] - try: - packet = osc_packet.OscPacket(data) - for timed_msg in packet.messages: - now = time.time() - handlers = self._dispatcher.handlers_for_address( - timed_msg.message.address) - if not handlers: - continue - # If the message is to be handled later, then so be it. - if timed_msg.time > now: - time.sleep(timed_msg.time - now) - for handler in handlers: - if inspect.iscoroutinefunction(handler.callback): - if handler.args: - result = await handler.callback( - timed_msg.message.address, handler.args, *timed_msg.message) - else: - result = await handler.callback(timed_msg.message.address, - *timed_msg.message) - else: - if handler.args: - result = handler.callback( - timed_msg.message.address, handler.args, *timed_msg.message) - else: - result = handler.callback(timed_msg.message.address, *timed_msg.message) - results.append(result) - except osc_packet.ParseError as e: - LOG.debug(f"Packet parse error: {str(e)}") - return results + if not isinstance(r, list): + r = [r] + msg = osc_message_builder.build_msg(r[0], r[1:]) + writer.write(slip.encode(msg.dgram)) + await writer.drain() diff --git a/pythonosc/slip.py b/pythonosc/slip.py index a3d2bcb..7333b17 100644 --- a/pythonosc/slip.py +++ b/pythonosc/slip.py @@ -1,14 +1,35 @@ -# Copyright (c) 2020. Ruud de Jong # This file is part of the SlipLib project which is released under the MIT license. # See https://github.com/rhjdjong/SlipLib for details. +# +# The MIT License (MIT) +# +# Copyright (c) 2015 Ruud de Jong +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. import re -END = b'\xc0' -ESC = b'\xdb' -ESC_END = b'\xdc' -ESC_ESC = b'\xdd' -END_END = b'\xc0\xc0' +END = b"\xc0" +ESC = b"\xdb" +ESC_END = b"\xdc" +ESC_ESC = b"\xdd" +END_END = b"\xc0\xc0" """These constants represent the special SLIP bytes""" @@ -38,7 +59,7 @@ def encode(msg: bytes) -> bytes: if msg: msg = bytes(msg) else: - msg = b'' + msg = b"" return END + msg.replace(ESC, ESC + ESC_ESC).replace(END, ESC + ESC_END) + END @@ -77,6 +98,8 @@ def is_valid(packet: bytes) -> bool: :const:`True` if the packet is valid, :const:`False` otherwise """ packet = packet.strip(END) - return not (END in packet or - packet.endswith(ESC) or - re.search(ESC + b'[^' + ESC_END + ESC_ESC + b']', packet)) + return not ( + END in packet + or packet.endswith(ESC) + or re.search(ESC + b"[^" + ESC_END + ESC_ESC + b"]", packet) + ) diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index e9c0107..512b666 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -1,10 +1,12 @@ """TCP Clients for sending OSC messages to an OSC server""" + import asyncio import socket import struct from typing import AsyncGenerator, Generator, List, Union from pythonosc import slip +from pythonosc.dispatcher import Dispatcher from pythonosc.osc_bundle import OscBundle from pythonosc.osc_message import OscMessage from pythonosc.osc_message_builder import ArgValue, build_msg @@ -14,8 +16,13 @@ class TCPClient(object): """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" - def __init__(self, address: str, port: int, - family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1) -> None: + def __init__( + self, + address: str, + port: int, + family: socket.AddressFamily = socket.AF_INET, + mode: str = MODE_1_1, + ) -> None: """Initialize client Args: @@ -46,7 +53,7 @@ def send(self, content: Union[OscMessage, OscBundle]) -> None: if self.mode == MODE_1_1: self.socket.sendall(slip.encode(content.dgram)) else: - b = struct.pack('!I', len(content.dgram)) + b = struct.pack("!I", len(content.dgram)) self.socket.sendall(b + content.dgram) def receive(self, timeout: int = 30) -> List[bytes]: @@ -70,12 +77,12 @@ def receive(self, timeout: int = 30) -> List[bytes]: buf += newbuf return [slip.decode(p) for p in buf.split(slip.END_END)] else: - buf = b'' + buf = b"" try: lengthbuf = self.socket.recv(4) except TimeoutError: return [] - length, = struct.unpack('!I', lengthbuf) + (length,) = struct.unpack("!I", lengthbuf) while length > 0: try: newbuf = self.socket.recv(length) @@ -93,10 +100,11 @@ def close(self): class SimpleTCPClient(TCPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def send_message(self, address: str, value: ArgValue = None) -> None: + def send_message(self, address: str, value: ArgValue = "") -> None: """Build :class:`OscMessage` from arguments and send to server Args: @@ -109,15 +117,21 @@ def send_message(self, address: str, value: ArgValue = None) -> None: def get_messages(self, timeout: int = 30) -> Generator: r = self.receive(timeout) while r: - yield OscMessage(r) + for m in r: + yield OscMessage(m) r = self.receive(timeout) -class AsyncOSCTCPClient: +class AsyncTCPClient: """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" - def __init__(self, address: str, port: int, - family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1) -> None: + def __init__( + self, + address: str, + port: int, + family: socket.AddressFamily = socket.AF_INET, + mode: str = MODE_1_1, + ) -> None: """Initialize client Args: @@ -125,8 +139,6 @@ def __init__(self, address: str, port: int, port: Port of server family: address family parameter (passed to socket.getaddrinfo) """ - self.reader: asyncio.StreamReader = None - self.writer: asyncio.StreamWriter = None self.address: str = address self.port: int = port self.mode: str = mode @@ -145,7 +157,8 @@ async def __aenter__(self): async def __open__(self): self.reader, self.writer = await asyncio.open_connection( - self.address, self.port) + self.address, self.port + ) async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() @@ -159,30 +172,47 @@ async def send(self, content: Union[OscMessage, OscBundle]) -> None: if self.mode == MODE_1_1: self.writer.write(slip.encode(content.dgram)) else: - b = struct.pack('!I', len(content.dgram)) + b = struct.pack("!I", len(content.dgram)) self.writer.write(b + content.dgram) await self.writer.drain() async def receive(self, timeout: int = 30) -> List[bytes]: - try: - async with asyncio.timeout(timeout): - buf = await self.reader.read(4096) - except TimeoutError: - return [] - if not buf: - return [] - # If the last byte is not an END marker there could be more data coming - while buf[-1] != 192: + if self.mode == MODE_1_1: + try: + buf = await asyncio.wait_for(self.reader.read(4096), timeout) + except TimeoutError: + return [] + if not buf: + return [] + # If the last byte is not an END marker there could be more data coming + while buf[-1] != 192: + try: + newbuf = await asyncio.wait_for(self.reader.read(4096), timeout) + except TimeoutError: + break + if not newbuf: + # Maybe should raise an exception here? + break + buf += newbuf + return [slip.decode(p) for p in buf.split(slip.END_END)] + else: + buf = b"" try: - async with asyncio.timeout(timeout): - newbuf = await self.reader.read(4096) - except asyncio.TimeoutError: - break - if not newbuf: - # Maybe should raise an exception here? - break - buf += newbuf - return [slip.decode(p) for p in buf.split(slip.END_END)] + lengthbuf = await asyncio.wait_for(self.reader.read(4), timeout) + except TimeoutError: + return [] + + (length,) = struct.unpack("!I", lengthbuf) + while length > 0: + try: + newbuf = await asyncio.wait_for(self.reader.read(length), timeout) + except TimeoutError: + return [] + if not newbuf: + return [] + buf += newbuf + length -= len(newbuf) + return [buf] async def close(self): self.writer.write_eof() @@ -190,12 +220,19 @@ async def close(self): await self.writer.wait_closed() -class AsyncSimpleTCPClient(AsyncOSCTCPClient): +class AsyncSimpleTCPClient(AsyncTCPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" - def __init__(self, address, port, family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1): + + def __init__( + self, + address: str, + port: int, + family: socket.AddressFamily = socket.AF_INET, + mode: str = MODE_1_1, + ): super().__init__(address, port, family, mode) - async def send_message(self, address: str, value: ArgValue = None) -> None: + async def send_message(self, address: str, value: ArgValue = "") -> None: """Build :class:`OscMessage` from arguments and send to server Args: @@ -211,3 +248,24 @@ async def get_messages(self, timeout: int = 30) -> AsyncGenerator: for m in r: yield OscMessage(m) r = await self.receive(timeout) + + +class AsyncDispatchTCPClient(AsyncTCPClient): + """OSC Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" + + dispatcher = Dispatcher() + + async def handle_messages(self, timeout: int = 30) -> None: + """Wait :int:`timeout` seconds for a message from the server and process each message with the registered + handlers. Continue until a timeout occurs. + + Args: + timeout: Time in seconds to wait for a message + """ + msgs = await self.receive(timeout) + while msgs: + for m in msgs: + await self.dispatcher.async_call_handlers_for_packet( + m, (self.address, self.port) + ) + msgs = await self.receive(timeout) diff --git a/pythonosc/test/test_osc_server.py b/pythonosc/test/test_osc_server.py index 1b38b1b..cf2bb15 100644 --- a/pythonosc/test/test_osc_server.py +++ b/pythonosc/test/test_osc_server.py @@ -1,8 +1,7 @@ import unittest import unittest.mock -from pythonosc import dispatcher -from pythonosc import osc_server +from pythonosc import dispatcher, osc_server _SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00" b",i\x00\x00" b"\x00\x00\x00\x04" @@ -33,6 +32,7 @@ def setUp(self): def test_no_match(self): mock_meth = unittest.mock.MagicMock() + mock_meth.return_value = None self.dispatcher.map("/foobar", mock_meth) osc_server._UDPHandler( [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server @@ -41,6 +41,7 @@ def test_no_match(self): def test_match_with_args(self): mock_meth = unittest.mock.MagicMock() + mock_meth.return_value = None self.dispatcher.map("/SYNC", mock_meth, 1, 2, 3) osc_server._UDPHandler( [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server @@ -49,6 +50,7 @@ def test_match_with_args(self): def test_match_int9(self): mock_meth = unittest.mock.MagicMock() + mock_meth.return_value = None self.dispatcher.map("/debug", mock_meth) osc_server._UDPHandler( [_SIMPLE_PARAM_INT_9, None], self.client_address, self.server @@ -58,6 +60,7 @@ def test_match_int9(self): def test_match_without_args(self): mock_meth = unittest.mock.MagicMock() + mock_meth.return_value = None self.dispatcher.map("/SYNC", mock_meth) osc_server._UDPHandler( [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server @@ -66,12 +69,48 @@ def test_match_without_args(self): def test_match_default_handler(self): mock_meth = unittest.mock.MagicMock() + mock_meth.return_value = None self.dispatcher.set_default_handler(mock_meth) osc_server._UDPHandler( [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server ) mock_meth.assert_called_with("/SYNC") + def test_response_no_args(self): + def respond(*args, **kwargs): + return "/SYNC" + + mock_sock = unittest.mock.Mock() + mock_sock.sendto = unittest.mock.Mock() + self.dispatcher.map("/SYNC", respond) + osc_server._UDPHandler( + (_SIMPLE_PARAM_INT_MSG, mock_sock), self.client_address, self.server + ) + mock_sock.sendto.assert_called_with( + b"/SYNC\00\00\00,\00\00\00", ("127.0.0.1", 8080) + ) + + def test_response_with_args(self): + def respond(*args, **kwargs): + return ( + "/SYNC", + 1, + "2", + 3.0, + ) + + self.dispatcher.map("/SYNC", respond) + mock_sock = unittest.mock.Mock() + mock_sock.sendto = unittest.mock.Mock() + self.dispatcher.map("/SYNC", respond) + osc_server._UDPHandler( + (_SIMPLE_PARAM_INT_MSG, mock_sock), self.client_address, self.server + ) + mock_sock.sendto.assert_called_with( + b"/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00", + ("127.0.0.1", 8080), + ) + if __name__ == "__main__": unittest.main() diff --git a/pythonosc/test/test_osc_tcp_server.py b/pythonosc/test/test_osc_tcp_server.py index 2743be8..f1ed2d9 100644 --- a/pythonosc/test/test_osc_tcp_server.py +++ b/pythonosc/test/test_osc_tcp_server.py @@ -5,29 +5,22 @@ from pythonosc import dispatcher, osc_tcp_server from pythonosc.slip import END -_SIMPLE_PARAM_INT_MSG = ( - b"/SYNC\x00\x00\x00" - b",i\x00\x00" - b"\x00\x00\x00\x04") +_SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00" b",i\x00\x00" b"\x00\x00\x00\x04" -LEN_SIMPLE_PARAM_INT_MSG = struct.pack('!I', len(_SIMPLE_PARAM_INT_MSG)) +LEN_SIMPLE_PARAM_INT_MSG = struct.pack("!I", len(_SIMPLE_PARAM_INT_MSG)) _SIMPLE_PARAM_INT_MSG_1_1 = END + _SIMPLE_PARAM_INT_MSG + END # Regression test for a datagram that should NOT be stripped, ever... -_SIMPLE_PARAM_INT_9 = b'/debug\x00\x00,i\x00\x00\x00\x00\x00\t' -LEN_SIMPLE_PARAM_INT_9 = struct.pack('!I', len(_SIMPLE_PARAM_INT_9)) +_SIMPLE_PARAM_INT_9 = b"/debug\x00\x00,i\x00\x00\x00\x00\x00\t" +LEN_SIMPLE_PARAM_INT_9 = struct.pack("!I", len(_SIMPLE_PARAM_INT_9)) _SIMPLE_PARAM_INT_9_1_1 = END + _SIMPLE_PARAM_INT_9 + END _SIMPLE_MSG_NO_PARAMS = b"/SYNC\x00\x00\x00" -LEN_SIMPLE_MSG_NO_PARAMS = struct.pack('!I', len(_SIMPLE_MSG_NO_PARAMS)) +LEN_SIMPLE_MSG_NO_PARAMS = struct.pack("!I", len(_SIMPLE_MSG_NO_PARAMS)) _SIMPLE_MSG_NO_PARAMS_1_1 = END + _SIMPLE_MSG_NO_PARAMS + END -class TestOscTcpServer(unittest.TestCase): - pass - - class TestTCP_1_1_Handler(unittest.TestCase): def setUp(self): super().setUp() @@ -36,7 +29,8 @@ def setUp(self): self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) # Need to attach property mocks to types, not objects... weird. type(self.server).dispatcher = unittest.mock.PropertyMock( - return_value=self.dispatcher) + return_value=self.dispatcher + ) self.client_address = ("127.0.0.1", 8080) self.mock_meth = unittest.mock.MagicMock() self.mock_meth.return_value = None @@ -45,27 +39,28 @@ def test_no_match(self): self.dispatcher.map("/foobar", self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, _SIMPLE_PARAM_INT_MSG_1_1, ""] - osc_tcp_server._TCPHandler1_1( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [ + _SIMPLE_MSG_NO_PARAMS_1_1, + _SIMPLE_PARAM_INT_MSG_1_1, + b"", + ] + osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) self.assertFalse(self.mock_meth.called) def test_match_with_args(self): self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, ""] - osc_tcp_server._TCPHandler1_1( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, b""] + osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) def test_match_int9(self): self.dispatcher.map("/debug", self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_9_1_1, ""] - osc_tcp_server._TCPHandler1_1( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_9_1_1, b""] + osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) self.assertTrue(self.mock_meth.called) self.mock_meth.assert_called_with("/debug", 9) @@ -73,45 +68,50 @@ def test_match_without_args(self): self.dispatcher.map("/SYNC", self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] - osc_tcp_server._TCPHandler1_1( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] + osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_match_default_handler(self): self.dispatcher.set_default_handler(self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] - osc_tcp_server._TCPHandler1_1( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] + osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_response_no_args(self): def respond(*args, **kwargs): return "/SYNC" + self.dispatcher.map("/SYNC", respond) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_1( - mock_sock, self.client_address, self.server) - mock_sock.sendall.assert_called_with(b'\xc0/SYNC\00\00\00,\00\00\00\xc0') + osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with(b"\xc0/SYNC\00\00\00,\00\00\00\xc0") def test_response_with_args(self): def respond(*args, **kwargs): - return ["/SYNC", 1, "2", 3.0,] + return [ + "/SYNC", + 1, + "2", + 3.0, + ] + self.dispatcher.map("/SYNC", respond) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, ""] + mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_1( - mock_sock, self.client_address, self.server) - mock_sock.sendall.assert_called_with(b'\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0') + osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with( + b"\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0" + ) class TestTCP_1_0_Handler(unittest.TestCase): @@ -122,7 +122,8 @@ def setUp(self): self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) # Need to attach property mocks to types, not objects... weird. type(self.server).dispatcher = unittest.mock.PropertyMock( - return_value=self.dispatcher) + return_value=self.dispatcher + ) self.client_address = ("127.0.0.1", 8080) self.mock_meth = unittest.mock.MagicMock() self.mock_meth.return_value = None @@ -131,28 +132,34 @@ def test_no_match(self): self.dispatcher.map("/foobar", self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, LEN_SIMPLE_PARAM_INT_MSG, - _SIMPLE_PARAM_INT_MSG, ""] - osc_tcp_server._TCPHandler1_0( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [ + LEN_SIMPLE_MSG_NO_PARAMS, + _SIMPLE_MSG_NO_PARAMS, + LEN_SIMPLE_PARAM_INT_MSG, + _SIMPLE_PARAM_INT_MSG, + b"", + ] + osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) self.assertFalse(self.mock_meth.called) def test_match_with_args(self): self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [LEN_SIMPLE_PARAM_INT_MSG, _SIMPLE_PARAM_INT_MSG, ""] - osc_tcp_server._TCPHandler1_0( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [ + LEN_SIMPLE_PARAM_INT_MSG, + _SIMPLE_PARAM_INT_MSG, + b"", + ] + osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) def test_match_int9(self): self.dispatcher.map("/debug", self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [LEN_SIMPLE_PARAM_INT_9, _SIMPLE_PARAM_INT_9, ""] - osc_tcp_server._TCPHandler1_0( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [LEN_SIMPLE_PARAM_INT_9, _SIMPLE_PARAM_INT_9, b""] + osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) self.assertTrue(self.mock_meth.called) self.mock_meth.assert_called_with("/debug", 9) @@ -160,45 +167,68 @@ def test_match_without_args(self): self.dispatcher.map("/SYNC", self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] - osc_tcp_server._TCPHandler1_0( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [ + LEN_SIMPLE_MSG_NO_PARAMS, + _SIMPLE_MSG_NO_PARAMS, + b"", + ] + osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_match_default_handler(self): self.dispatcher.set_default_handler(self.mock_meth) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] - osc_tcp_server._TCPHandler1_0( - mock_sock, self.client_address, self.server) + mock_sock.recv.side_effect = [ + LEN_SIMPLE_MSG_NO_PARAMS, + _SIMPLE_MSG_NO_PARAMS, + b"", + ] + osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_response_no_args(self): def respond(*args, **kwargs): return "/SYNC" + self.dispatcher.map("/SYNC", respond) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] + mock_sock.recv.side_effect = [ + LEN_SIMPLE_MSG_NO_PARAMS, + _SIMPLE_MSG_NO_PARAMS, + b"", + ] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_0( - mock_sock, self.client_address, self.server) - mock_sock.sendall.assert_called_with(b'\x00\x00\x00\x0c/SYNC\00\00\00,\00\00\00') + osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with( + b"\x00\x00\x00\x0c/SYNC\00\00\00,\00\00\00" + ) def test_response_with_args(self): def respond(*args, **kwargs): - return ["/SYNC", 1, "2", 3.0,] + return [ + "/SYNC", + 1, + "2", + 3.0, + ] + self.dispatcher.map("/SYNC", respond) mock_sock = mock.Mock() mock_sock.recv = mock.Mock() - mock_sock.recv.side_effect = [LEN_SIMPLE_MSG_NO_PARAMS, _SIMPLE_MSG_NO_PARAMS, ""] + mock_sock.recv.side_effect = [ + LEN_SIMPLE_MSG_NO_PARAMS, + _SIMPLE_MSG_NO_PARAMS, + b"", + ] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_0( - mock_sock, self.client_address, self.server) - mock_sock.sendall.assert_called_with(b'\x00\x00\x00\x1c/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00') + osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + mock_sock.sendall.assert_called_with( + b"\x00\x00\x00\x1c/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00" + ) class TestAsync1_1Handler(unittest.IsolatedAsyncioTestCase): @@ -209,7 +239,8 @@ def setUp(self): self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) # Need to attach property mocks to types, not objects... weird. type(self.server).dispatcher = unittest.mock.PropertyMock( - return_value=self.dispatcher) + return_value=self.dispatcher + ) self.client_address = ("127.0.0.1", 8080) self.mock_writer = mock.Mock() self.mock_writer.close = mock.Mock() @@ -219,56 +250,103 @@ def setUp(self): self.mock_writer.wait_closed = mock.AsyncMock() self.mock_reader = mock.Mock() self.mock_reader.read = mock.AsyncMock() - self.server = osc_tcp_server.AsyncOSCTCPServer("127.0.0.1", 8008, self.dispatcher) + self.server = osc_tcp_server.AsyncOSCTCPServer( + "127.0.0.1", 8008, self.dispatcher + ) self.mock_meth = unittest.mock.MagicMock() self.mock_meth.return_value = None async def test_no_match(self): self.dispatcher.map("/foobar", self.mock_meth) - self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, _SIMPLE_PARAM_INT_MSG_1_1, b''] - await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_reader.read.side_effect = [ + _SIMPLE_MSG_NO_PARAMS_1_1, + _SIMPLE_PARAM_INT_MSG_1_1, + b"", + ] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) self.assertFalse(self.mock_meth.called) async def test_match_with_args(self): self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) - self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, b''] - await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, b""] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) async def test_match_int9(self): self.dispatcher.map("/debug", self.mock_meth) - self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_9_1_1, b''] - await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_9_1_1, b""] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) self.assertTrue(self.mock_meth.called) self.mock_meth.assert_called_with("/debug", 9) async def test_match_without_args(self): self.dispatcher.map("/SYNC", self.mock_meth) - self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] - await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) self.mock_meth.assert_called_with("/SYNC") async def test_match_default_handler(self): self.dispatcher.set_default_handler(self.mock_meth) - self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] - await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) self.mock_meth.assert_called_with("/SYNC") async def test_response_no_args(self): def respond(*args, **kwargs): return "/SYNC" + self.dispatcher.map("/SYNC", respond) - self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] - await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) - self.mock_writer.write.assert_called_with(b'\xc0/SYNC\00\00\00,\00\00\00\xc0') + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) + self.mock_writer.write.assert_called_with(b"\xc0/SYNC\00\00\00,\00\00\00\xc0") async def test_response_with_args(self): def respond(*args, **kwargs): - return ["/SYNC", 1, "2", 3.0,] + return [ + "/SYNC", + 1, + "2", + 3.0, + ] + + self.dispatcher.map("/SYNC", respond) + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) + self.mock_writer.write.assert_called_with( + b"\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0" + ) + + async def test_async_response_with_args(self): + async def respond(*args, **kwargs): + return [ + "/SYNC", + 1, + "2", + 3.0, + ] + self.dispatcher.map("/SYNC", respond) - self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b''] - await osc_tcp_server.AsyncOSCTCPServer.handle(self.server, self.mock_reader, self.mock_writer) - self.mock_writer.write.assert_called_with(b'\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0') + self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] + await osc_tcp_server.AsyncOSCTCPServer.handle( + self.server, self.mock_reader, self.mock_writer + ) + self.mock_writer.write.assert_called_with( + b"\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0" + ) if __name__ == "__main__": diff --git a/pythonosc/test/test_tcp_client.py b/pythonosc/test/test_tcp_client.py index 37990e4..a0089f4 100644 --- a/pythonosc/test/test_tcp_client.py +++ b/pythonosc/test/test_tcp_client.py @@ -6,7 +6,7 @@ class TestTcpClient(unittest.TestCase): - @mock.patch('socket.socket') + @mock.patch("socket.socket") def test_client(self, mock_socket_ctor): mock_socket = mock_socket_ctor.return_value mock_send = mock.Mock() @@ -16,12 +16,12 @@ def test_client(self, mock_socket_ctor): mock_socket.sendall = mock_send mock_socket.recv = mock_recv - msg = osc_message_builder.OscMessageBuilder('/').build() - with tcp_client.TCPClient('::1', 31337) as client: + msg = osc_message_builder.OscMessageBuilder("/").build() + with tcp_client.TCPClient("::1", 31337) as client: client.send(msg) mock_socket.sendall.assert_called_once_with(slip.encode(msg.dgram)) - @mock.patch('socket.socket') + @mock.patch("socket.socket") def test_simple_client(self, mock_socket_ctor): mock_socket = mock_socket_ctor.return_value mock_send = mock.Mock() @@ -31,13 +31,13 @@ def test_simple_client(self, mock_socket_ctor): mock_socket.sendall = mock_send mock_socket.recv = mock_recv - with tcp_client.SimpleTCPClient('::1', 31337) as client: - client.send_message('/', []) + with tcp_client.SimpleTCPClient("::1", 31337) as client: + client.send_message("/", []) mock_socket.sendall.assert_called_once() class TestAsyncTcpClient(unittest.IsolatedAsyncioTestCase): - @mock.patch('asyncio.open_connection') + @mock.patch("asyncio.open_connection") async def test_send(self, mock_socket_ctor): mock_reader = mock.Mock() mock_writer = mock.Mock() @@ -46,22 +46,22 @@ async def test_send(self, mock_socket_ctor): mock_socket_ctor.return_value = (mock_reader, mock_writer) loop = asyncio.get_running_loop() loop.set_debug(False) - msg = osc_message_builder.OscMessageBuilder('/').build() - async with tcp_client.AsyncOSCTCPClient('::1', 31337) as client: + msg = osc_message_builder.OscMessageBuilder("/").build() + async with tcp_client.AsyncTCPClient("::1", 31337) as client: await client.send(msg) self.assertTrue(mock_writer.write.called) mock_writer.write.assert_called_once_with(slip.encode(msg.dgram)) - @mock.patch('asyncio.open_connection') + @mock.patch("asyncio.open_connection") async def test_send_message_calls_send_with_msg(self, mock_socket_ctor): mock_reader = mock.Mock() mock_writer = mock.Mock() mock_writer.drain = mock.AsyncMock() mock_writer.wait_closed = mock.AsyncMock() mock_socket_ctor.return_value = (mock_reader, mock_writer) - async with tcp_client.AsyncSimpleTCPClient('::1', 31337) as client: - await client.send_message('/address', 1) + async with tcp_client.AsyncSimpleTCPClient("::1", 31337) as client: + await client.send_message("/address", 1) self.assertTrue(mock_writer.write.called) diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index c4cce00..319e9f0 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -8,12 +8,12 @@ from collections import Iterable import socket +from typing import Generator, Union -from .osc_message_builder import OscMessageBuilder, ArgValue -from pythonosc.osc_message import OscMessage +from pythonosc.dispatcher import Dispatcher from pythonosc.osc_bundle import OscBundle - -from typing import Union +from pythonosc.osc_message import OscMessage +from pythonosc.osc_message_builder import ArgValue, OscMessageBuilder class UDPClient(object): @@ -63,6 +63,18 @@ def send(self, content: Union[OscMessage, OscBundle]) -> None: """ self._sock.sendto(content.dgram, (self._address, self._port)) + def receive(self, timeout: int = 30) -> bytes: + """Wait :int:`timeout` seconds for a message an return the raw bytes + + Args: + timeout: Number of seconds to wait for a message + """ + self._sock.settimeout(timeout) + try: + return self._sock.recv(4096) + except TimeoutError: + return b"" + class SimpleUDPClient(UDPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" @@ -75,6 +87,7 @@ def send_message(self, address: str, value: ArgValue) -> None: value: One or more arguments to be added to the message """ builder = OscMessageBuilder(address=address) + values: ArgValue if value is None: pass elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): @@ -84,3 +97,32 @@ def send_message(self, address: str, value: ArgValue) -> None: builder.add_arg(val) msg = builder.build() self.send(msg) + + def get_messages(self, timeout: int = 30) -> Generator: + """Wait :int:`timeout` seconds for a message from the server and convert it to a :class:`OscMessage` + + Args: + timeout: Time in seconds to wait for a message + """ + msg = self.receive(timeout) + while msg: + yield OscMessage(msg) + msg = self.receive(timeout) + + +class DispatchClient(SimpleUDPClient): + """OSC Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" + + dispatcher = Dispatcher() + + def handle_messages(self, timeout: int = 30) -> None: + """Wait :int:`timeout` seconds for a message from the server and process each message with the registered + handlers. Continue until a timeout occurs. + + Args: + timeout: Time in seconds to wait for a message + """ + msg = self.receive(timeout) + while msg: + self.dispatcher.call_handlers_for_packet(msg, (self._address, self._port)) + msg = self.receive(timeout) From 900a818da73c08aadedda06dac7b73b1bc5ba900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Sat, 17 Aug 2024 17:07:59 +0000 Subject: [PATCH 22/69] push version 1.9.0 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34581d7..0a0e7ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +## [1.9.0] + - Added TCP Client and Server support for OSC 1.0 and OSC 1.1 formats, with support for sending responses to the client - Added response support to the existing UDP Client and Server code diff --git a/pyproject.toml b/pyproject.toml index e1277dd..40585da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-osc" -version="1.8.3" +version="1.9.0" description="Open Sound Control server and client implementations in pure Python" readme="README.rst" requires-python=">=3.10" From a4138c5ea3e54cb8b013093a109f0b0ae28a0837 Mon Sep 17 00:00:00 2001 From: Bob Haddleton Date: Sat, 17 Aug 2024 13:02:22 -0500 Subject: [PATCH 23/69] Update README.rst with TCP support information Signed-off-by: Bob Haddleton --- README.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index d5dbf6e..61870c0 100644 --- a/README.rst +++ b/README.rst @@ -18,10 +18,12 @@ and is currently in a stable state. Features ======== -* UDP blocking/threading/forking/asyncio server implementations -* UDP client +* UDP and TCP blocking/threading/forking/asyncio server implementations +* UDP and TCP clients, including asyncio support +* TCP support for 1.0 and 1.1 protocol formats * int, int64, float, string, double, MIDI, timestamps, blob, nil OSC arguments * simple OSC address<->callback matching system +* support for sending responses from callback handlers in client and server * extensive unit test coverage * basic client and server examples From 3f7546a4dc9bcb82ddb0f6f32ba4fe411a7a4c84 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 15 Oct 2024 11:14:25 +0300 Subject: [PATCH 24/69] Move all setup.cfg configuration into pyproject.toml --- pyproject.toml | 40 +++++++++++++++++++++++++++++++++++++++- setup.cfg | 35 ----------------------------------- 2 files changed, 39 insertions(+), 36 deletions(-) delete mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 40585da..932daa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + [project] name = "python-osc" version="1.9.0" @@ -17,5 +21,39 @@ classifiers=[ 'Topic :: Multimedia :: Sound/Audio', 'Topic :: System :: Networking', ] + [project.urls] -Repository = "https://github.com/attwad/python-osc" \ No newline at end of file +Repository = "https://github.com/attwad/python-osc" + +[tool.mypy] +# Would be great to turn this on, however there's too many cases it would break +# right now. +# disallow_any_generics = true + +disallow_subclassing_any = true + +# Allow functions _without_ type annotations, but require that annotations be +# complete (possibly including the `Any` type) where they are present. +disallow_incomplete_defs = true +# check_untyped_defs = true +disallow_untyped_decorators = true + + +# # Would be great to turn these on eventually +# no_implicit_optional = true +# strict_optional = true + +warn_redundant_casts = true +warn_unused_ignores = true +show_error_codes = true +# # Would be great to turn this on eventually +# # warn_return_any = true +# warn_unreachable = true + +# implicit_reexport = False +# strict_equality = true + +scripts_are_modules = true +warn_unused_configs = true + +enable_error_code = "ignore-without-code" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 629d4d8..0000000 --- a/setup.cfg +++ /dev/null @@ -1,35 +0,0 @@ -[metadata] -license_files = LICENSE.txt - -[mypy] -# Would be great to turn this on, however there's too many cases it would break -# right now. -# disallow_any_generics = True - -disallow_subclassing_any = True - -# Allow functions _without_ type annotations, but require that annotations be -# complete (possibly including the `Any` type) where they are present. -disallow_incomplete_defs = True -# check_untyped_defs = True -disallow_untyped_decorators = True - - -# # Would be great to turn these on eventually -# no_implicit_optional = True -# strict_optional = True - -warn_redundant_casts = True -warn_unused_ignores = True -show_error_codes = True -# # Would be great to turn this on eventually -# # warn_return_any = True -# warn_unreachable = True - -# implicit_reexport = False -# strict_equality = True - -scripts_are_modules = True -warn_unused_configs = True - -enable_error_code = ignore-without-code From 37a0007bf343cca7537c93c8482ebf2eadfc41f0 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 15 Oct 2024 11:17:26 +0300 Subject: [PATCH 25/69] Reformat pyproject.toml with dprint --- pyproject.toml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 932daa2..1da67ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,22 +4,22 @@ build-backend = "setuptools.build_meta" [project] name = "python-osc" -version="1.9.0" -description="Open Sound Control server and client implementations in pure Python" -readme="README.rst" -requires-python=">=3.10" -license = {file = "LICENSE.txt"} +version = "1.9.0" +description = "Open Sound Control server and client implementations in pure Python" +readme = "README.rst" +requires-python = ">=3.10" +license = { file = "LICENSE.txt" } authors = [ - {name = "attwad", email = "tmusoft@gmail.com"}, + { name = "attwad", email = "tmusoft@gmail.com" }, ] keywords = ["osc", "sound", "midi", "music"] -classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: Freely Distributable', - 'Programming Language :: Python :: 3', - 'Topic :: Multimedia :: Sound/Audio', - 'Topic :: System :: Networking', +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: Freely Distributable", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: System :: Networking", ] [project.urls] @@ -38,7 +38,6 @@ disallow_incomplete_defs = true # check_untyped_defs = true disallow_untyped_decorators = true - # # Would be great to turn these on eventually # no_implicit_optional = true # strict_optional = true From d2437f2156f3fd64902279f144359fb675fd2caa Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 15 Oct 2024 11:26:47 +0300 Subject: [PATCH 26/69] Run `flynt` to transform formatting to f-strings --- examples/simple_2way.py | 6 +++--- examples/simple_echo_server.py | 2 +- examples/simple_server.py | 6 +++--- examples/simple_tcp_server.py | 6 +++--- pythonosc/dispatcher.py | 3 +-- pythonosc/osc_bundle.py | 6 +++--- pythonosc/osc_bundle_builder.py | 2 +- pythonosc/osc_message.py | 10 +++------- pythonosc/osc_message_builder.py | 6 ++---- pythonosc/osc_packet.py | 2 +- pythonosc/parsing/osc_types.py | 34 ++++++++++++++++---------------- scripts/print_datagrams_main.py | 4 ++-- 12 files changed, 40 insertions(+), 47 deletions(-) diff --git a/examples/simple_2way.py b/examples/simple_2way.py index 2a7b555..9d2bc38 100644 --- a/examples/simple_2way.py +++ b/examples/simple_2way.py @@ -15,11 +15,11 @@ def print_fader_handler(unused_addr, args, value): - print("[{0}] ~ {1:0.2f}".format(args[0], value)) + print(f"[{args[0]}] ~ {value:0.2f}") def print_xy_fader_handler(unused_addr, args, value1, value2): - print("[{0}] ~ {1:0.2f} ~ {2:0.2f}".format(args[0], value2, value1)) + print(f"[{args[0]}] ~ {value2:0.2f} ~ {value1:0.2f}") if __name__ == "__main__": @@ -54,7 +54,7 @@ def print_xy_fader_handler(unused_addr, args, value1, value2): def start_server(ip, port): print("Starting Server") server = osc_server.ThreadingOSCUDPServer((ip, port), dispatcher) - print("Serving on {}".format(server.server_address)) + print(f"Serving on {server.server_address}") thread = threading.Thread(target=server.serve_forever) thread.start() diff --git a/examples/simple_echo_server.py b/examples/simple_echo_server.py index 9347326..9351595 100644 --- a/examples/simple_echo_server.py +++ b/examples/simple_echo_server.py @@ -26,5 +26,5 @@ def echo_handler(client_addr, unused_addr, args): dispatcher.set_default_handler(echo_handler, True) server = osc_server.ThreadingOSCUDPServer((args.ip, args.port), dispatcher) - print("Serving on {}".format(server.server_address)) + print(f"Serving on {server.server_address}") server.serve_forever() diff --git a/examples/simple_server.py b/examples/simple_server.py index a862457..5913174 100644 --- a/examples/simple_server.py +++ b/examples/simple_server.py @@ -12,12 +12,12 @@ def print_volume_handler(unused_addr, args, volume): - print("[{0}] ~ {1}".format(args[0], volume)) + print(f"[{args[0]}] ~ {volume}") def print_compute_handler(unused_addr, args, volume): try: - print("[{0}] ~ {1}".format(args[0], args[1](volume))) + print(f"[{args[0]}] ~ {args[1](volume)}") except ValueError: pass @@ -34,5 +34,5 @@ def print_compute_handler(unused_addr, args, volume): dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) server = osc_server.ThreadingOSCUDPServer((args.ip, args.port), dispatcher) - print("Serving on {}".format(server.server_address)) + print(f"Serving on {server.server_address}") server.serve_forever() diff --git a/examples/simple_tcp_server.py b/examples/simple_tcp_server.py index 13c9a50..c1bb0c8 100644 --- a/examples/simple_tcp_server.py +++ b/examples/simple_tcp_server.py @@ -12,12 +12,12 @@ def print_volume_handler(unused_addr, args, volume): - print("[{0}] ~ {1}".format(args[0], volume)) + print(f"[{args[0]}] ~ {volume}") def print_compute_handler(unused_addr, args, volume): try: - print("[{0}] ~ {1}".format(args[0], args[1](volume))) + print(f"[{args[0]}] ~ {args[1](volume)}") except ValueError: pass @@ -42,5 +42,5 @@ def print_compute_handler(unused_addr, args, volume): server = osc_tcp_server.ThreadingOSCTCPServer( (args.ip, args.port), dispatcher, mode=args.mode ) - print("Serving on {}".format(server.server_address)) + print(f"Serving on {server.server_address}") server.serve_forever() diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index 7f99c0d..c3996a7 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -175,8 +175,7 @@ def unmap(self, address, handler, *args, needs_reply_address=False): except ValueError as e: if str(e) == "list.remove(x): x not in list": raise ValueError( - "Address '%s' doesn't have handler '%s' mapped to it" - % (address, handler) + f"Address '{address}' doesn't have handler '{handler}' mapped to it" ) from e def handlers_for_address( diff --git a/pythonosc/osc_bundle.py b/pythonosc/osc_bundle.py index 9598624..8868e4d 100644 --- a/pythonosc/osc_bundle.py +++ b/pythonosc/osc_bundle.py @@ -33,7 +33,7 @@ def __init__(self, dgram: bytes) -> None: try: self._timestamp, index = osc_types.get_date(self._dgram, index) except osc_types.ParseError as pe: - raise ParseError("Could not get the date from the datagram: %s" % pe) + raise ParseError(f"Could not get the date from the datagram: {pe}") # Get the contents as a list of OscBundle and OscMessage. self._contents = self._parse_contents(index) @@ -61,10 +61,10 @@ def _parse_contents( contents.append(osc_message.OscMessage(content_dgram)) else: logging.warning( - "Could not identify content type of dgram %r" % content_dgram + f"Could not identify content type of dgram {content_dgram!r}" ) except (osc_types.ParseError, osc_message.ParseError, IndexError) as e: - raise ParseError("Could not parse a content datagram: %s" % e) + raise ParseError(f"Could not parse a content datagram: {e}") return contents diff --git a/pythonosc/osc_bundle_builder.py b/pythonosc/osc_bundle_builder.py index 2779269..bf42664 100644 --- a/pythonosc/osc_bundle_builder.py +++ b/pythonosc/osc_bundle_builder.py @@ -58,4 +58,4 @@ def build(self) -> osc_bundle.OscBundle: ) return osc_bundle.OscBundle(dgram) except osc_types.BuildError as be: - raise BuildError("Could not build the bundle {}".format(be)) + raise BuildError(f"Could not build the bundle {be}") diff --git a/pythonosc/osc_message.py b/pythonosc/osc_message.py index 180372d..26e1842 100644 --- a/pythonosc/osc_message.py +++ b/pythonosc/osc_message.py @@ -73,21 +73,17 @@ def _parse_datagram(self) -> None: elif param == "]": # Array stop. if len(param_stack) < 2: raise ParseError( - "Unexpected closing bracket in type tag: {0}".format( - type_tag - ) + f"Unexpected closing bracket in type tag: {type_tag}" ) param_stack.pop() # TODO: Support more exotic types as described in the specification. else: - logging.warning("Unhandled parameter type: {0}".format(param)) + logging.warning(f"Unhandled parameter type: {param}") continue if param not in "[]": param_stack[-1].append(val) if len(param_stack) != 1: - raise ParseError( - "Missing closing bracket in type tag: {0}".format(type_tag) - ) + raise ParseError(f"Missing closing bracket in type tag: {type_tag}") self._parameters = params except osc_types.ParseError as pe: raise ParseError("Found incorrect datagram, ignoring it", pe) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index b51f2b8..64c354c 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -188,13 +188,11 @@ def build(self) -> osc_message.OscMessage: ): continue else: - raise BuildError( - "Incorrect parameter type found {}".format(arg_type) - ) + raise BuildError(f"Incorrect parameter type found {arg_type}") return osc_message.OscMessage(dgram) except osc_types.BuildError as be: - raise BuildError("Could not build the message: {}".format(be)) + raise BuildError(f"Could not build the message: {be}") def build_msg(address: str, value: ArgValue = "") -> osc_message.OscMessage: diff --git a/pythonosc/osc_packet.py b/pythonosc/osc_packet.py index 9589c90..3f94c57 100644 --- a/pythonosc/osc_packet.py +++ b/pythonosc/osc_packet.py @@ -76,7 +76,7 @@ def __init__(self, dgram: bytes) -> None: "OscBundle." ) except (osc_bundle.ParseError, osc_message.ParseError) as pe: - raise ParseError("Could not parse packet %s" % pe) + raise ParseError(f"Could not parse packet {pe}") @property def messages(self) -> List[TimedMessage]: diff --git a/pythonosc/parsing/osc_types.py b/pythonosc/parsing/osc_types.py index 5d0ba48..85c4c33 100644 --- a/pythonosc/parsing/osc_types.py +++ b/pythonosc/parsing/osc_types.py @@ -43,7 +43,7 @@ def write_string(val: str) -> bytes: try: dgram = val.encode("utf-8") # Default, but better be explicit. except (UnicodeEncodeError, AttributeError) as e: - raise BuildError("Incorrect string, could not encode {}".format(e)) + raise BuildError(f"Incorrect string, could not encode {e}") diff = _STRING_DGRAM_PAD - (len(dgram) % _STRING_DGRAM_PAD) dgram += b"\x00" * diff return dgram @@ -90,9 +90,9 @@ def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]: data_str = dgram[start_index : start_index + offset] return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset except IndexError as ie: - raise ParseError("Could not parse datagram %s" % ie) + raise ParseError(f"Could not parse datagram {ie}") except TypeError as te: - raise ParseError("Could not parse datagram %s" % te) + raise ParseError(f"Could not parse datagram {te}") def write_int(val: int) -> bytes: @@ -104,7 +104,7 @@ def write_int(val: int) -> bytes: try: return struct.pack(">i", val) except struct.error as e: - raise BuildError("Wrong argument value passed: {}".format(e)) + raise BuildError(f"Wrong argument value passed: {e}") def get_int(dgram: bytes, start_index: int) -> Tuple[int, int]: @@ -128,7 +128,7 @@ def get_int(dgram: bytes, start_index: int) -> Tuple[int, int]: start_index + _INT_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram %s" % e) + raise ParseError(f"Could not parse datagram {e}") def write_int64(val: int) -> bytes: @@ -140,7 +140,7 @@ def write_int64(val: int) -> bytes: try: return struct.pack(">q", val) except struct.error as e: - raise BuildError("Wrong argument value passed: {}".format(e)) + raise BuildError(f"Wrong argument value passed: {e}") def get_int64(dgram: bytes, start_index: int) -> Tuple[int, int]: @@ -164,7 +164,7 @@ def get_int64(dgram: bytes, start_index: int) -> Tuple[int, int]: start_index + _INT64_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram %s" % e) + raise ParseError(f"Could not parse datagram {e}") def get_uint64(dgram: bytes, start_index: int) -> Tuple[int, int]: @@ -190,7 +190,7 @@ def get_uint64(dgram: bytes, start_index: int) -> Tuple[int, int]: start_index + _UINT64_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram %s" % e) + raise ParseError(f"Could not parse datagram {e}") def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], int]: @@ -223,7 +223,7 @@ def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], i return (utc, fraction), start_index + _TIMETAG_DGRAM_LEN except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram %s" % e) + raise ParseError(f"Could not parse datagram {e}") def write_float(val: float) -> bytes: @@ -235,7 +235,7 @@ def write_float(val: float) -> bytes: try: return struct.pack(">f", val) except struct.error as e: - raise BuildError("Wrong argument value passed: {}".format(e)) + raise BuildError(f"Wrong argument value passed: {e}") def get_float(dgram: bytes, start_index: int) -> Tuple[float, int]: @@ -262,7 +262,7 @@ def get_float(dgram: bytes, start_index: int) -> Tuple[float, int]: start_index + _FLOAT_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram %s" % e) + raise ParseError(f"Could not parse datagram {e}") def write_double(val: float) -> bytes: @@ -274,7 +274,7 @@ def write_double(val: float) -> bytes: try: return struct.pack(">d", val) except struct.error as e: - raise BuildError("Wrong argument value passed: {}".format(e)) + raise BuildError(f"Wrong argument value passed: {e}") def get_double(dgram: bytes, start_index: int) -> Tuple[float, int]: @@ -300,7 +300,7 @@ def get_double(dgram: bytes, start_index: int) -> Tuple[float, int]: start_index + _DOUBLE_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram {}".format(e)) + raise ParseError(f"Could not parse datagram {e}") def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]: @@ -393,7 +393,7 @@ def write_rgba(val: bytes) -> bytes: try: return struct.pack(">I", val) except struct.error as e: - raise BuildError("Wrong argument value passed: {}".format(e)) + raise BuildError(f"Wrong argument value passed: {e}") def get_rgba(dgram: bytes, start_index: int) -> Tuple[bytes, int]: @@ -417,7 +417,7 @@ def get_rgba(dgram: bytes, start_index: int) -> Tuple[bytes, int]: start_index + _INT_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram %s" % e) + raise ParseError(f"Could not parse datagram {e}") def write_midi(val: MidiPacket) -> bytes: @@ -435,7 +435,7 @@ def write_midi(val: MidiPacket) -> bytes: value = sum((value & 0xFF) << 8 * (3 - pos) for pos, value in enumerate(val)) return struct.pack(">I", value) except struct.error as e: - raise BuildError("Wrong argument value passed: {}".format(e)) + raise BuildError(f"Wrong argument value passed: {e}") def get_midi(dgram: bytes, start_index: int) -> Tuple[MidiPacket, int]: @@ -460,4 +460,4 @@ def get_midi(dgram: bytes, start_index: int) -> Tuple[MidiPacket, int]: ) return (midi_msg, start_index + _INT_DGRAM_LEN) except (struct.error, TypeError) as e: - raise ParseError("Could not parse datagram %s" % e) + raise ParseError(f"Could not parse datagram {e}") diff --git a/scripts/print_datagrams_main.py b/scripts/print_datagrams_main.py index ff26d0c..23e687a 100644 --- a/scripts/print_datagrams_main.py +++ b/scripts/print_datagrams_main.py @@ -16,10 +16,10 @@ def main(): def _PrintOscMessages(ip, port): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((ip, port)) - print("Listening for UDP packets on {0}:{1} ...".format(ip, port)) + print(f"Listening for UDP packets on {ip}:{port} ...") while True: data, _ = sock.recvfrom(1024) - print("%s" % data) + print(f"{data}") if __name__ == "__main__": From c140c8eb806809c6f818b5593a30cf1727817514 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 15 Oct 2024 11:27:07 +0300 Subject: [PATCH 27/69] Run `flynt -tc` to transform concatenation to f-strings --- pythonosc/dispatcher.py | 2 +- pythonosc/osc_message_builder.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index c3996a7..680f26d 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -201,7 +201,7 @@ def handlers_for_address( pattern = pattern.replace("\\*", "[\\w|\\+]*") # The rest of the syntax in the specification is like the re module so # we're fine. - pattern = pattern + "$" + pattern = f"{pattern}$" patterncompiled = re.compile(pattern) matched = False diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 64c354c..fc4a035 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -161,7 +161,7 @@ def build(self) -> osc_message.OscMessage: # Write the parameters. arg_types = "".join([arg[0] for arg in self._args]) - dgram += osc_types.write_string("," + arg_types) + dgram += osc_types.write_string(f",{arg_types}") for arg_type, value in self._args: if arg_type == self.ARG_TYPE_STRING: dgram += osc_types.write_string(value) # type: ignore[arg-type] From 78455964d2b10fc07822da9d6aca480d70dc92b8 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 15 Oct 2024 11:32:35 +0300 Subject: [PATCH 28/69] Fix remaining `.format()` calls to f-strings --- pythonosc/osc_bundle_builder.py | 3 +-- pythonosc/osc_message_builder.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/pythonosc/osc_bundle_builder.py b/pythonosc/osc_bundle_builder.py index bf42664..ee28d6f 100644 --- a/pythonosc/osc_bundle_builder.py +++ b/pythonosc/osc_bundle_builder.py @@ -53,8 +53,7 @@ def build(self) -> osc_bundle.OscBundle: dgram += content.dgram else: raise BuildError( - "Content must be either OscBundle or OscMessage" - "found {}".format(type(content)) + f"Content must be either OscBundle or OscMessage, found {type(content)}" ) return osc_bundle.OscBundle(dgram) except osc_types.BuildError as be: diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index fc4a035..ebbc2cb 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -90,9 +90,7 @@ def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: """ if arg_type and not self._valid_type(arg_type): raise ValueError( - "arg_type must be one of {}, or an array of valid types".format( - self._SUPPORTED_ARG_TYPES - ) + f"arg_type must be one of {self._SUPPORTED_ARG_TYPES}, or an array of valid types" ) if not arg_type: arg_type = self._get_arg_type(arg_value) From bbeaad4f025b0960e488894bd70b545cdb1561cd Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 15 Oct 2024 11:35:32 +0300 Subject: [PATCH 29/69] README: clarify how to send bundles Via https://stackoverflow.com/a/79088975 --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 61870c0..a40202f 100644 --- a/README.rst +++ b/README.rst @@ -148,7 +148,8 @@ Building bundles # The bundle has 5 elements in total now. bundle = bundle.build() - # You can now send it via a client as described in other examples. + # You can now send it via a client with the `.send()` method: + client.send(bundle) License? ======== From 32cf9c190bf4bfcf3ddc1659e7c809b52721eda2 Mon Sep 17 00:00:00 2001 From: tmu Date: Sun, 22 Dec 2024 18:10:28 +0100 Subject: [PATCH 30/69] update version to 1.9.1 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0e7ea..1ab10fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +## [1.9.1] + +- Reinstate mistakenly deleted package type annotations + ## [1.9.0] - Added TCP Client and Server support for OSC 1.0 and OSC 1.1 formats, with support for sending responses to the client diff --git a/pyproject.toml b/pyproject.toml index 1da67ea..f6b972b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-osc" -version = "1.9.0" +version = "1.9.1" description = "Open Sound Control server and client implementations in pure Python" readme = "README.rst" requires-python = ">=3.10" From cc7563687d72b5a18d55322734c2733b641101a0 Mon Sep 17 00:00:00 2001 From: tmu Date: Sun, 22 Dec 2024 18:24:25 +0100 Subject: [PATCH 31/69] bump version to 1.9.2 and reinstate types --- CHANGELOG.md | 4 ++++ py.typed | 0 pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 py.typed diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab10fa..19b6f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +## [1.9.2] + +- Reinstate mistakenly deleted package type annotations on main branch + ## [1.9.1] - Reinstate mistakenly deleted package type annotations diff --git a/py.typed b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index f6b972b..8fe746b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-osc" -version = "1.9.1" +version = "1.9.2" description = "Open Sound Control server and client implementations in pure Python" readme = "README.rst" requires-python = ">=3.10" From ce29bb69666781d1f0c078034160079ef201266f Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 22 Dec 2024 18:11:38 +0000 Subject: [PATCH 32/69] Add CI step to validate type publication --- .github/workflows/python-test.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 79cd04f..c2ad1bd 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -38,3 +38,26 @@ jobs: steps: - uses: actions/checkout@v3 - uses: psf/black@stable + + check-types-published: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: | + pip install build + python -m build --sdist --wheel + + temp=$(mktemp -d) + + python -m venv $temp/venv + source $temp/venv/bin/activate + + pip install mypy ./dist/*whl + + cd $temp + + echo 'import pythonosc' > demo.py + mypy demo.py From fdcb4d2bd6b64833e66ddb7e2dbfeb841ad1899b Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 22 Dec 2024 18:16:54 +0000 Subject: [PATCH 33/69] Move py.typed where it needs to go --- py.typed => pythonosc/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename py.typed => pythonosc/py.typed (100%) diff --git a/py.typed b/pythonosc/py.typed similarity index 100% rename from py.typed rename to pythonosc/py.typed From e50f4813ca20e7f009e13f2c1347fdbdaa82c542 Mon Sep 17 00:00:00 2001 From: tmu Date: Mon, 23 Dec 2024 21:24:08 +0100 Subject: [PATCH 34/69] bump version for types --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b6f94..947cf0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +## [1.9.3] + +- Reinstate mistakenly deleted package type annotations on main branch (again) + ## [1.9.2] - Reinstate mistakenly deleted package type annotations on main branch diff --git a/pyproject.toml b/pyproject.toml index 8fe746b..8768236 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-osc" -version = "1.9.2" +version = "1.9.3" description = "Open Sound Control server and client implementations in pure Python" readme = "README.rst" requires-python = ">=3.10" From f8244e504f271c5bc1c0f1b0c563412699a5bba8 Mon Sep 17 00:00:00 2001 From: s0600204 Date: Fri, 3 Jan 2025 21:11:38 +0000 Subject: [PATCH 35/69] Expect Dispatcher replies to be tuples, not lists As mentioned in issue 176, according to the documentation one of the ways a callback function may pass a reply back to a client is by returning a message encapsulated thusly: `tuple(
, )`. Whilst the code for the UDP servers does conform to this, the code to TCP servers was expecting `list(
, )` in all cases. This PR resolves that, updating the tests as well. --- pythonosc/osc_tcp_server.py | 8 ++++---- pythonosc/test/test_osc_tcp_server.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pythonosc/osc_tcp_server.py b/pythonosc/osc_tcp_server.py index 56e0816..aec674f 100644 --- a/pythonosc/osc_tcp_server.py +++ b/pythonosc/osc_tcp_server.py @@ -75,7 +75,7 @@ def handle(self) -> None: ) # resp = _call_handlers_for_packet(data, self.server.dispatcher) for r in resp: - if not isinstance(r, list): + if not isinstance(r, tuple): r = [r] msg = osc_message_builder.build_msg(r[0], r[1:]) b = struct.pack("!I", len(msg.dgram)) @@ -117,7 +117,7 @@ def handle(self) -> None: p, self.client_address ) for r in resp: - if not isinstance(r, list): + if not isinstance(r, tuple): r = [r] msg = osc_message_builder.build_msg(r[0], r[1:]) self.request.sendall(slip.encode(msg.dgram)) @@ -284,7 +284,7 @@ async def handle1_0( buf, client_address ) for r in result: - if not isinstance(r, list): + if not isinstance(r, tuple): r = [r] msg = osc_message_builder.build_msg(r[0], r[1:]) b = struct.pack("!I", len(msg.dgram)) @@ -319,7 +319,7 @@ async def handle_1_1( p, client_address ) for r in result: - if not isinstance(r, list): + if not isinstance(r, tuple): r = [r] msg = osc_message_builder.build_msg(r[0], r[1:]) writer.write(slip.encode(msg.dgram)) diff --git a/pythonosc/test/test_osc_tcp_server.py b/pythonosc/test/test_osc_tcp_server.py index f1ed2d9..dd1d1d8 100644 --- a/pythonosc/test/test_osc_tcp_server.py +++ b/pythonosc/test/test_osc_tcp_server.py @@ -95,12 +95,12 @@ def respond(*args, **kwargs): def test_response_with_args(self): def respond(*args, **kwargs): - return [ + return ( "/SYNC", 1, "2", 3.0, - ] + ) self.dispatcher.map("/SYNC", respond) mock_sock = mock.Mock() @@ -208,12 +208,12 @@ def respond(*args, **kwargs): def test_response_with_args(self): def respond(*args, **kwargs): - return [ + return ( "/SYNC", 1, "2", 3.0, - ] + ) self.dispatcher.map("/SYNC", respond) mock_sock = mock.Mock() @@ -314,12 +314,12 @@ def respond(*args, **kwargs): async def test_response_with_args(self): def respond(*args, **kwargs): - return [ + return ( "/SYNC", 1, "2", 3.0, - ] + ) self.dispatcher.map("/SYNC", respond) self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] @@ -332,12 +332,12 @@ def respond(*args, **kwargs): async def test_async_response_with_args(self): async def respond(*args, **kwargs): - return [ + return ( "/SYNC", 1, "2", 3.0, - ] + ) self.dispatcher.map("/SYNC", respond) self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] From feaa6b8c720df6731a38d44a7dadaf3d3373b0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Sat, 4 Jan 2025 09:10:25 +0100 Subject: [PATCH 36/69] Update CHANGELOG.md for latest PR wrt dispatcher annotations --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 947cf0f..6c3a803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +- Fixed TPC dispatcher type annotations + ## [1.9.3] - Reinstate mistakenly deleted package type annotations on main branch (again) From 9040270ee715250894464731c795f1b26eafbbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Thu, 13 Feb 2025 10:31:35 +0100 Subject: [PATCH 37/69] Create .readthedocs.yaml as per https://docs.readthedocs.com/platform/stable/config-file/index.html --- .readthedocs.yaml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..dd2aa46 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,35 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + # You can also specify other tool versions: + # nodejs: "20" + # rust: "1.70" + # golang: "1.20" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + # builder: "dirhtml" + # Fail on all warnings to avoid broken references + # fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +# formats: +# - pdf +# - epub + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt From 551d5ed8df313ba099316a78501786cdc1679d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9?= Date: Thu, 13 Feb 2025 10:35:47 +0100 Subject: [PATCH 38/69] Update conf.py to use standard sphinx theme --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index f14a2b2..fdc8faa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,7 +82,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +# html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the From 3963916efeccdb779584b48c9839ceda59fbe85a Mon Sep 17 00:00:00 2001 From: "fraser.todd" Date: Mon, 11 Aug 2025 14:13:07 +0100 Subject: [PATCH 39/69] OscBundleBuilder's contents now have correct typing --- pythonosc/osc_bundle_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pythonosc/osc_bundle_builder.py b/pythonosc/osc_bundle_builder.py index ee28d6f..a8fe5d5 100644 --- a/pythonosc/osc_bundle_builder.py +++ b/pythonosc/osc_bundle_builder.py @@ -25,9 +25,9 @@ def __init__(self, timestamp: int) -> None: seconds since the epoch in UTC or IMMEDIATELY. """ self._timestamp = timestamp - self._contents: List[osc_bundle.OscBundle] = [] + self._contents: List[osc_bundle.OscBundle | osc_message.OscMessage] = [] - def add_content(self, content: osc_bundle.OscBundle) -> None: + def add_content(self, content: osc_bundle.OscBundle | osc_message.OscMessage) -> None: """Add a new content to this bundle. Args: From c9edc65113cb640f9461af3de928c5d1382c03ae Mon Sep 17 00:00:00 2001 From: "fraser.todd" Date: Mon, 11 Aug 2025 14:28:01 +0100 Subject: [PATCH 40/69] removed unused ignore --- pythonosc/osc_message_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index ebbc2cb..c17da45 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -96,7 +96,7 @@ def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: arg_type = self._get_arg_type(arg_value) if isinstance(arg_type, list): self._args.append((self.ARG_TYPE_ARRAY_START, None)) - for v, t in zip(arg_value, arg_type): # type: ignore[var-annotated, arg-type] + for v, t in zip(arg_value, arg_type): # type: ignore[arg-type] self.add_arg(v, t) self._args.append((self.ARG_TYPE_ARRAY_STOP, None)) else: From 12fa77a038bab9ab8fd1e9248a1ec0531ed85d5c Mon Sep 17 00:00:00 2001 From: "fraser.todd" Date: Mon, 11 Aug 2025 16:36:50 +0100 Subject: [PATCH 41/69] black linting --- pythonosc/dispatcher.py | 3 +-- pythonosc/osc_bundle_builder.py | 4 +++- pythonosc/osc_server.py | 3 +-- pythonosc/test/parsing/test_osc_types.py | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index 680f26d..0ea9480 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -1,5 +1,4 @@ -"""Maps OSC addresses to handler functions -""" +"""Maps OSC addresses to handler functions""" import collections import inspect diff --git a/pythonosc/osc_bundle_builder.py b/pythonosc/osc_bundle_builder.py index a8fe5d5..37af6bb 100644 --- a/pythonosc/osc_bundle_builder.py +++ b/pythonosc/osc_bundle_builder.py @@ -27,7 +27,9 @@ def __init__(self, timestamp: int) -> None: self._timestamp = timestamp self._contents: List[osc_bundle.OscBundle | osc_message.OscMessage] = [] - def add_content(self, content: osc_bundle.OscBundle | osc_message.OscMessage) -> None: + def add_content( + self, content: osc_bundle.OscBundle | osc_message.OscMessage + ) -> None: """Add a new content to this bundle. Args: diff --git a/pythonosc/osc_server.py b/pythonosc/osc_server.py index b2fd5a9..aad0162 100644 --- a/pythonosc/osc_server.py +++ b/pythonosc/osc_server.py @@ -1,5 +1,4 @@ -"""OSC Servers that receive UDP packets and invoke handlers accordingly. -""" +"""OSC Servers that receive UDP packets and invoke handlers accordingly.""" import asyncio import os diff --git a/pythonosc/test/parsing/test_osc_types.py b/pythonosc/test/parsing/test_osc_types.py index 052a161..55e70b3 100644 --- a/pythonosc/test/parsing/test_osc_types.py +++ b/pythonosc/test/parsing/test_osc_types.py @@ -85,10 +85,10 @@ def test_get_rgba(self): b"\x00\x00\x00\x01": (1, 4), b"\x00\x00\x00\x02": (2, 4), b"\x00\x00\x00\x03": (3, 4), - b"\xFF\x00\x00\x00": (4278190080, 4), - b"\x00\xFF\x00\x00": (16711680, 4), - b"\x00\x00\xFF\x00": (65280, 4), - b"\x00\x00\x00\xFF": (255, 4), + b"\xff\x00\x00\x00": (4278190080, 4), + b"\x00\xff\x00\x00": (16711680, 4), + b"\x00\x00\xff\x00": (65280, 4), + b"\x00\x00\x00\xff": (255, 4), b"\x00\x00\x00\x01GARBAGE": (1, 4), } @@ -164,7 +164,7 @@ def test_get_timetag(self): (datetime(1900, 1, 1, 0, 0, 0), 0), 8, ), - b"\x83\xaa\x7E\x80\x0A\x00\xB0\x0C": ( + b"\x83\xaa\x7e\x80\x0a\x00\xb0\x0c": ( (datetime(1970, 1, 1, 0, 0, 0), 167817228), 8, ), From a17159ccf6e54e5369b9572756e4780da463fde3 Mon Sep 17 00:00:00 2001 From: "fraser.todd" Date: Mon, 11 Aug 2025 16:39:14 +0100 Subject: [PATCH 42/69] black linting --- pythonosc/osc_bundle_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonosc/osc_bundle_builder.py b/pythonosc/osc_bundle_builder.py index 37af6bb..7b9bf20 100644 --- a/pythonosc/osc_bundle_builder.py +++ b/pythonosc/osc_bundle_builder.py @@ -28,7 +28,7 @@ def __init__(self, timestamp: int) -> None: self._contents: List[osc_bundle.OscBundle | osc_message.OscMessage] = [] def add_content( - self, content: osc_bundle.OscBundle | osc_message.OscMessage + self, content: osc_bundle.OscBundle | osc_message.OscMessage ) -> None: """Add a new content to this bundle. From 0a6178781180dae61c2de74d58009acd4fc79031 Mon Sep 17 00:00:00 2001 From: Liam Steckler Date: Sat, 26 Jul 2025 17:06:35 -0700 Subject: [PATCH 43/69] Support ArgValue being None to send an empty message --- pythonosc/osc_message_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index c17da45..9e79079 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -5,7 +5,7 @@ from pythonosc import osc_message from pythonosc.parsing import osc_types -ArgValue = Union[str, bytes, bool, int, float, osc_types.MidiPacket, list] +ArgValue = Union[str, bytes, bool, int, float, osc_types.MidiPacket, list, None] class BuildError(Exception): From b254751bf668cc4614addc629af171e70ba2fb73 Mon Sep 17 00:00:00 2001 From: my1e5 <10064103+my1e5@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:52:22 +0100 Subject: [PATCH 44/69] fix: correct spelling of 'inferred' --- pythonosc/osc_message_builder.py | 2 +- pythonosc/test/test_osc_message_builder.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 9e79079..abdbf62 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -134,7 +134,7 @@ def _get_arg_type(self, arg_value: ArgValue) -> Union[str, Any]: elif arg_value is None: arg_type = self.ARG_TYPE_NIL else: - raise ValueError("Infered arg_value type is not supported") + raise ValueError("Inferred arg_value type is not supported") return arg_type def build(self) -> osc_message.OscMessage: diff --git a/pythonosc/test/test_osc_message_builder.py b/pythonosc/test/test_osc_message_builder.py index abd2f3b..f74c6a2 100644 --- a/pythonosc/test/test_osc_message_builder.py +++ b/pythonosc/test/test_osc_message_builder.py @@ -19,7 +19,7 @@ def test_wrong_param_raise(self): builder = osc_message_builder.OscMessageBuilder("") self.assertRaises(ValueError, builder.add_arg, "what?", 1) - def test_add_arg_invalid_infered_type(self): + def test_add_arg_invalid_inferred_type(self): builder = osc_message_builder.OscMessageBuilder("") self.assertRaises(ValueError, builder.add_arg, {"name": "John"}) From 5a89a7a4bcd3be2356f3ead74a375fe0ce539463 Mon Sep 17 00:00:00 2001 From: jms5194 Date: Mon, 20 Oct 2025 16:11:41 -0400 Subject: [PATCH 45/69] adding TCPDispatchClient Class --- pythonosc/tcp_client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index 512b666..3a6c63c 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -121,6 +121,25 @@ def get_messages(self, timeout: int = 30) -> Generator: yield OscMessage(m) r = self.receive(timeout) +class TCPDispatchClient(SimpleTCPClient): + """OSC Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" + + dispatcher = Dispatcher() + + def handle_messages(self, timeout: int = 30) -> None: + """Wait :int:`timeout` seconds for a message from the server and process each message with the registered + handlers. Continue until a timeout occurs. + + Args: + timeout: Time in seconds to wait for a message + """ + r = self.receive(timeout) + while r: + for m in r: + self.dispatcher.call_handlers_for_packet(m, (self.address, self.port)) + r = self.receive(timeout) + + class AsyncTCPClient: """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" From 594ed0285f108340c74361a296df232ffbf1e852 Mon Sep 17 00:00:00 2001 From: jms5194 Date: Mon, 20 Oct 2025 16:23:11 -0400 Subject: [PATCH 46/69] adding TCPDispatchClient Class --- pythonosc/tcp_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index 3a6c63c..a838c22 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -122,7 +122,7 @@ def get_messages(self, timeout: int = 30) -> Generator: r = self.receive(timeout) class TCPDispatchClient(SimpleTCPClient): - """OSC Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" + """OSC TCP Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" dispatcher = Dispatcher() From 51d3a5b3ee00f2e41520fba7e67b42c9bd180e1a Mon Sep 17 00:00:00 2001 From: jms5194 Date: Mon, 20 Oct 2025 16:24:24 -0400 Subject: [PATCH 47/69] updates for Lint --- pythonosc/tcp_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index a838c22..162a3f2 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -121,6 +121,7 @@ def get_messages(self, timeout: int = 30) -> Generator: yield OscMessage(m) r = self.receive(timeout) + class TCPDispatchClient(SimpleTCPClient): """OSC TCP Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" @@ -140,7 +141,6 @@ def handle_messages(self, timeout: int = 30) -> None: r = self.receive(timeout) - class AsyncTCPClient: """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" From a75800537dafb431009db1221ed6acca8b6217be Mon Sep 17 00:00:00 2001 From: jms5194 Date: Tue, 21 Oct 2025 12:12:10 -0400 Subject: [PATCH 48/69] modified changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3a803..01a9d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +- Added TCPDispatchClient to tcp_client - Fixed TPC dispatcher type annotations ## [1.9.3] From 8677a061e8f28c44554f3c8f5f6fc721918ef0a5 Mon Sep 17 00:00:00 2001 From: jms5194 Date: Sat, 25 Oct 2025 07:58:02 -0400 Subject: [PATCH 49/69] update to arg, requested changes --- pythonosc/tcp_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index 162a3f2..4ca622c 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -127,18 +127,18 @@ class TCPDispatchClient(SimpleTCPClient): dispatcher = Dispatcher() - def handle_messages(self, timeout: int = 30) -> None: + def handle_messages(self, timeout_sec: int = 30) -> None: """Wait :int:`timeout` seconds for a message from the server and process each message with the registered handlers. Continue until a timeout occurs. Args: timeout: Time in seconds to wait for a message """ - r = self.receive(timeout) + r = self.receive(timeout_sec) while r: for m in r: self.dispatcher.call_handlers_for_packet(m, (self.address, self.port)) - r = self.receive(timeout) + r = self.receive(timeout_sec) class AsyncTCPClient: From 791ea4afc5ab8ba88ad07fa5ea4b7f66fb895d71 Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 16:31:42 +0200 Subject: [PATCH 50/69] use a context manager to automatically close the socket of UDPClient. Closes #193 --- pythonosc/test/test_udp_client.py | 16 ++++++++++++++++ pythonosc/udp_client.py | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/pythonosc/test/test_udp_client.py b/pythonosc/test/test_udp_client.py index dece381..c7ee32f 100644 --- a/pythonosc/test/test_udp_client.py +++ b/pythonosc/test/test_udp_client.py @@ -48,5 +48,21 @@ def test_send_message_calls_add_arg_multiple_times_with_list(self): self.assertEqual(self.builder.add_arg.call_count, 3) +class TestUdpClientClose(unittest.TestCase): + @mock.patch("socket.socket") + def test_close(self, mock_socket_ctor): + mock_socket = mock_socket_ctor.return_value + client = udp_client.UDPClient("::1", 31337) + client.close() + self.assertTrue(mock_socket.close.called) + + @mock.patch("socket.socket") + def test_context_manager(self, mock_socket_ctor): + mock_socket = mock_socket_ctor.return_value + with udp_client.UDPClient("::1", 31337) as client: + self.assertIsInstance(client, udp_client.UDPClient) + self.assertTrue(mock_socket.close.called) + + if __name__ == "__main__": unittest.main() diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index 319e9f0..32d765a 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -55,6 +55,16 @@ def __init__( self._address = address self._port = port + def __enter__(self) -> "UDPClient": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + def close(self) -> None: + """Close the socket""" + self._sock.close() + def send(self, content: Union[OscMessage, OscBundle]) -> None: """Sends an :class:`OscMessage` or :class:`OscBundle` via UDP From 1f295c55d713de008c3629d2bffca67d760bfcab Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 16:34:04 +0200 Subject: [PATCH 51/69] add documentation about context manager for udp client --- docs/client.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/client.rst b/docs/client.rst index 2db133d..b84a4ea 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -23,6 +23,10 @@ Examples client.send_message("/some/address", 123) # Send float message client.send_message("/some/address", [1, 2., "hello"]) # Send message with int, float and string + # Alternatively, use a context manager to automatically close the socket + with SimpleUDPClient(ip, port) as client: + client.send_message("/some/address", 123) + .. code-block:: python From ae787d11fb2ac58b48ebb51be52e8e4d7ed4b5f2 Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 16:36:17 +0200 Subject: [PATCH 52/69] added missing type annotations --- pythonosc/udp_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index 32d765a..23fb7cf 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -58,7 +58,7 @@ def __init__( def __enter__(self) -> "UDPClient": return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object | None) -> None: self.close() def close(self) -> None: From 8b5a5f298f900306fd4d0b32d8dbaa3c91e8259c Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 17:17:57 +0200 Subject: [PATCH 53/69] fixed regexp parsing, enforced ',' on begining of datagrams as per spec and also fixed time parsing (when > 24h) --- pythonosc/dispatcher.py | 64 +++++++++++++++++++++++------- pythonosc/osc_message.py | 8 +++- pythonosc/osc_message_builder.py | 2 +- pythonosc/parsing/osc_types.py | 30 ++++++-------- pythonosc/test/test_osc_message.py | 10 +---- pythonosc/udp_client.py | 4 +- 6 files changed, 72 insertions(+), 46 deletions(-) diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index 0ea9480..8e3e37e 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -182,28 +182,62 @@ def handlers_for_address( ) -> Generator[Handler, None, None]: """Yields handlers matching an address - Args: address_pattern: Address to match Returns: Generator yielding Handlers matching address_pattern """ - # First convert the address_pattern into a matchable regexp. - # '?' in the OSC Address Pattern matches any single character. - # Let's consider numbers and _ "characters" too here, it's not said - # explicitly in the specification but it sounds good. - escaped_address_pattern = re.escape(address_pattern) - pattern = escaped_address_pattern.replace("\\?", "\\w?") - # '*' in the OSC Address Pattern matches any sequence of zero or more - # characters. - pattern = pattern.replace("\\*", "[\\w|\\+]*") - # The rest of the syntax in the specification is like the re module so - # we're fine. - pattern = f"{pattern}$" - patterncompiled = re.compile(pattern) - matched = False + # Convert OSC Address Pattern to a Python regular expression. + # Spec: https://opensoundcontrol.stanford.edu/spec-1_0.html#osc-address-patterns + + pattern = "^" + i = 0 + while i < len(address_pattern): + c = address_pattern[i] + if c == "*": + pattern += "[^/]*" + elif c == "?": + pattern += "[^/]" + elif c == "[": + pattern += "[" + i += 1 + if i < len(address_pattern) and address_pattern[i] == "!": + pattern += "^" + i += 1 + while i < len(address_pattern) and address_pattern[i] != "]": + if address_pattern[i] in r"\^$.|()+*?": + pattern += "\\" + pattern += address_pattern[i] + i += 1 + pattern += "]" + elif c == "{": + pattern += "(" + i += 1 + while i < len(address_pattern) and address_pattern[i] != "}": + char = address_pattern[i] + if char == ",": + pattern += "|" + elif char in r"\^$.|()[]+*?": + pattern += "\\" + char + else: + pattern += char + i += 1 + pattern += ")" + elif c in r"\^$.|()[]+?": + pattern += "\\" + c + else: + pattern += c + i += 1 + pattern += "$" + try: + patterncompiled = re.compile(pattern) + except re.error: + # If the pattern is invalid, it won't match anything. + return + + matched = False for addr, handlers in self._map.items(): if patterncompiled.match(addr) or ( ("*" in addr) diff --git a/pythonosc/osc_message.py b/pythonosc/osc_message.py index 26e1842..3898add 100644 --- a/pythonosc/osc_message.py +++ b/pythonosc/osc_message.py @@ -34,8 +34,12 @@ def _parse_datagram(self) -> None: # Get the parameters types. type_tag, index = osc_types.get_string(self._dgram, index) - if type_tag.startswith(","): - type_tag = type_tag[1:] + if not type_tag.startswith(","): + raise ParseError( + f"OSC Type Tag String must start with a comma, got: {type_tag}" + ) + + type_tag = type_tag[1:] params = [] # type: List[Any] param_stack = [params] diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index abdbf62..9b0d15e 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -121,7 +121,7 @@ def _get_arg_type(self, arg_value: ArgValue) -> Union[str, Any]: elif arg_value is False: arg_type = self.ARG_TYPE_FALSE elif isinstance(arg_value, int): - if arg_value.bit_length() > 32: + if arg_value.bit_length() > 31: arg_type = self.ARG_TYPE_INT64 else: arg_type = self.ARG_TYPE_INT diff --git a/pythonosc/parsing/osc_types.py b/pythonosc/parsing/osc_types.py index 85c4c33..4a85541 100644 --- a/pythonosc/parsing/osc_types.py +++ b/pythonosc/parsing/osc_types.py @@ -71,24 +71,21 @@ def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]: raise ParseError("start_index < 0") offset = 0 try: - if ( - len(dgram) > start_index + _STRING_DGRAM_PAD - and dgram[start_index + _STRING_DGRAM_PAD] == _EMPTY_STR_DGRAM - ): - return "", start_index + _STRING_DGRAM_PAD while dgram[start_index + offset] != 0: offset += 1 - # Align to a byte word. - if (offset) % _STRING_DGRAM_PAD == 0: - offset += _STRING_DGRAM_PAD - else: - offset += -offset % _STRING_DGRAM_PAD - # Python slices do not raise an IndexError past the last index, - # do it ourselves. - if offset > len(dgram[start_index:]): + + # OSC spec: "followed by a null, followed by 0-3 additional null characters + # to make the total number of bits a multiple of 32" + # This means the total length (including the first null) must be a multiple of 4. + total_len = offset + 1 + if total_len % 4 != 0: + total_len += 4 - (total_len % 4) + + if start_index + total_len > len(dgram): raise ParseError("Datagram is too short") + data_str = dgram[start_index : start_index + offset] - return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset + return data_str.decode("utf-8"), start_index + total_len except IndexError as ie: raise ParseError(f"Could not parse datagram {ie}") except TypeError as te: @@ -214,11 +211,8 @@ def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], i timetag, _ = get_uint64(dgram, start_index) seconds, fraction = ntp.parse_timestamp(timetag) - hours, seconds = seconds // 3600, seconds % 3600 - minutes, seconds = seconds // 60, seconds % 60 - utc = datetime.combine(ntp._NTP_EPOCH, datetime.min.time()) + timedelta( - hours=hours, minutes=minutes, seconds=seconds + seconds=seconds ) return (utc, fraction), start_index + _TIMETAG_DGRAM_LEN diff --git a/pythonosc/test/test_osc_message.py b/pythonosc/test/test_osc_message.py index 269901b..c030fe2 100644 --- a/pythonosc/test/test_osc_message.py +++ b/pythonosc/test/test_osc_message.py @@ -22,15 +22,7 @@ b"\x00\x00\x00\x08stuff\x00\x00\x00" ) # b"stuff\x00\x00\x00" -_DGRAM_ALL_NON_STANDARD_TYPES_OF_PARAMS = ( - b"/SYNC\x00\x00\x00" - b"T" # True - b"F" # False - b"N" # Nil - b"[]th\x00" # Empty array - b"\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\xe8\xd4\xa5\x10\x00" # 1000000000000 -) +_DGRAM_ALL_NON_STANDARD_TYPES_OF_PARAMS = b"/SYNC\x00\x00\x00,TFN[]th\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe8\xd4\xa5\x10\x00" _DGRAM_COMPLEX_ARRAY_PARAMS = ( b"/SYNC\x00\x00\x00" diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index 23fb7cf..e540b9e 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -58,7 +58,9 @@ def __init__( def __enter__(self) -> "UDPClient": return self - def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object | None) -> None: + def __exit__( + self, exc_type: type | None, exc_val: Exception | None, exc_tb: object | None + ) -> None: self.close() def close(self) -> None: From c3b141048e28dc90efc75e2498ef7e55cce2c871 Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 17:20:57 +0200 Subject: [PATCH 54/69] switch from black to ruff and use uv overall --- .github/workflows/publish-pypi.yml | 20 +- .github/workflows/python-test.yml | 49 ++- examples/async_server.py | 5 +- examples/simple_echo_server.py | 1 - pyproject.toml | 17 +- pythonosc/dispatcher.py | 2 +- pythonosc/osc_message_builder.py | 1 - pythonosc/osc_packet.py | 3 +- pythonosc/test/test_osc_bundle.py | 14 +- pythonosc/test/test_osc_message.py | 6 +- pythonosc/test/test_osc_packet.py | 2 +- pythonosc/test/test_osc_server.py | 2 +- pythonosc/test/test_osc_tcp_server.py | 2 +- pythonosc/udp_client.py | 1 - uv.lock | 482 ++++++++++++++++++++++++++ 15 files changed, 539 insertions(+), 68 deletions(-) create mode 100644 uv.lock diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index da0fcad..45ffa6f 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -9,24 +9,12 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: python-version: "3.x" - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - name: Build a binary wheel and a source tarball - run: >- - python3 -m - build - --sdist - --wheel - --outdir dist/ - . + run: uv build - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file + run: uv publish \ No newline at end of file diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index c2ad1bd..35a73ff 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -17,47 +17,42 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest mypy - - name: Lint with flake8 + run: uv sync --group dev + - name: Lint with ruff run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + uv run ruff check . - name: Check with mypy - run: mypy pythonosc examples + run: uv run mypy pythonosc examples - name: Test with pytest - run: pytest + run: uv run pytest lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: psf/black@stable + - uses: actions/checkout@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v5 + - name: Run ruff format + run: uv run ruff format --check . check-types-published: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: python-version: '3.12' - - run: | - pip install build - python -m build --sdist --wheel - + - name: Build package + run: uv build + - name: Verify package installation and types + run: | temp=$(mktemp -d) - - python -m venv $temp/venv - source $temp/venv/bin/activate - - pip install mypy ./dist/*whl - - cd $temp - - echo 'import pythonosc' > demo.py - mypy demo.py + uv run --with mypy --with ./dist/*.whl --no-project -- python -c "import pythonosc" + echo 'import pythonosc' > $temp/demo.py + uv run --with mypy --with ./dist/*.whl --no-project -- mypy $temp/demo.py diff --git a/examples/async_server.py b/examples/async_server.py index 87945eb..ada205f 100644 --- a/examples/async_server.py +++ b/examples/async_server.py @@ -24,7 +24,10 @@ async def loop(): async def init_main(): server = AsyncIOOSCUDPServer((ip, port), dispatcher, asyncio.get_event_loop()) - transport, protocol = ( + ( + transport, + protocol, + ) = ( await server.create_serve_endpoint() ) # Create datagram endpoint and start serving diff --git a/examples/simple_echo_server.py b/examples/simple_echo_server.py index 9351595..9bc042e 100644 --- a/examples/simple_echo_server.py +++ b/examples/simple_echo_server.py @@ -5,7 +5,6 @@ """ import argparse -import math from pythonosc.dispatcher import Dispatcher from pythonosc import osc_server diff --git a/pyproject.toml b/pyproject.toml index 8768236..04814ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" +requires = ["uv-build"] +build-backend = "uv_build" [project] name = "python-osc" @@ -21,10 +21,23 @@ classifiers = [ "Topic :: Multimedia :: Sound/Audio", "Topic :: System :: Networking", ] +dependencies = [] [project.urls] Repository = "https://github.com/attwad/python-osc" +[dependency-groups] +dev = [ + "pytest", + "mypy", + "ruff", + "pytest-cov", +] + +[tool.uv.build-backend] +module-name = "pythonosc" +module-root = "." + [tool.mypy] # Would be great to turn this on, however there's too many cases it would break # right now. diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index 8e3e37e..ea7f360 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -350,7 +350,7 @@ async def async_call_handlers_for_packet( ) if result: results.append(result) - except osc_packet.ParseError as e: + except osc_packet.ParseError: pass return results diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 9b0d15e..8efe9b2 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -195,7 +195,6 @@ def build(self) -> osc_message.OscMessage: def build_msg(address: str, value: ArgValue = "") -> osc_message.OscMessage: builder = OscMessageBuilder(address=address) - values: ArgValue if value == "": values = [] elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): diff --git a/pythonosc/osc_packet.py b/pythonosc/osc_packet.py index 3f94c57..0ab17fb 100644 --- a/pythonosc/osc_packet.py +++ b/pythonosc/osc_packet.py @@ -72,8 +72,7 @@ def __init__(self, dgram: bytes) -> None: else: # Empty packet, should not happen as per the spec but heh, UDP... raise ParseError( - "OSC Packet should at least contain an OscMessage or an " - "OscBundle." + "OSC Packet should at least contain an OscMessage or an OscBundle." ) except (osc_bundle.ParseError, osc_message.ParseError) as pe: raise ParseError(f"Could not parse packet {pe}") diff --git a/pythonosc/test/test_osc_bundle.py b/pythonosc/test/test_osc_bundle.py index 7e7fc54..d881971 100644 --- a/pythonosc/test/test_osc_bundle.py +++ b/pythonosc/test/test_osc_bundle.py @@ -46,7 +46,7 @@ b"?\x00\x00\x00" ) -_DGRAM_EMPTY_BUNDLE = b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" +_DGRAM_EMPTY_BUNDLE = b"#bundle\x00\x00\x00\x00\x00\x00\x00\x00\x01" _DGRAM_BUNDLE_IN_BUNDLE = ( b"#bundle\x00" @@ -60,20 +60,14 @@ b"?\x00\x00\x00" ) -_DGRAM_INVALID = b"#bundle\x00" b"\x00\x00\x00" +_DGRAM_INVALID = b"#bundle\x00\x00\x00\x00" _DGRAM_INVALID_INDEX = ( - b"#bundle\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x01" - b"\x00\x00\x00\x20" - b"/SYNC\x00\x00\x00\x00" + b"#bundle\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x20/SYNC\x00\x00\x00\x00" ) _DGRAM_UNKNOWN_TYPE = ( - b"#bundle\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x01" - b"\x00\x00\x00\x10" - b"iamnotaslash" + b"#bundle\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x10iamnotaslash" ) diff --git a/pythonosc/test/test_osc_message.py b/pythonosc/test/test_osc_message.py index c030fe2..d490c5c 100644 --- a/pythonosc/test/test_osc_message.py +++ b/pythonosc/test/test_osc_message.py @@ -5,11 +5,11 @@ from datetime import datetime # Datagrams sent by Reaktor 5.8 by Native Instruments (c). -_DGRAM_KNOB_ROTATES = b"/FB\x00" b",f\x00\x00" b">xca=q" +_DGRAM_KNOB_ROTATES = b"/FB\x00,f\x00\x00>xca=q" -_DGRAM_SWITCH_GOES_OFF = b"/SYNC\x00\x00\x00" b",f\x00\x00" b"\x00\x00\x00\x00" +_DGRAM_SWITCH_GOES_OFF = b"/SYNC\x00\x00\x00,f\x00\x00\x00\x00\x00\x00" -_DGRAM_SWITCH_GOES_ON = b"/SYNC\x00\x00\x00" b",f\x00\x00" b"?\x00\x00\x00" +_DGRAM_SWITCH_GOES_ON = b"/SYNC\x00\x00\x00,f\x00\x00?\x00\x00\x00" _DGRAM_NO_PARAMS = b"/SYNC\x00\x00\x00" diff --git a/pythonosc/test/test_osc_packet.py b/pythonosc/test/test_osc_packet.py index 0d150f2..d92d69a 100644 --- a/pythonosc/test/test_osc_packet.py +++ b/pythonosc/test/test_osc_packet.py @@ -17,7 +17,7 @@ b"?\x00\x00\x00" ) -_DGRAM_EMPTY_BUNDLE = b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" +_DGRAM_EMPTY_BUNDLE = b"#bundle\x00\x00\x00\x00\x00\x00\x00\x00\x01" _DGRAM_NESTED_MESS = ( b"#bundle\x00" diff --git a/pythonosc/test/test_osc_server.py b/pythonosc/test/test_osc_server.py index cf2bb15..342bd08 100644 --- a/pythonosc/test/test_osc_server.py +++ b/pythonosc/test/test_osc_server.py @@ -3,7 +3,7 @@ from pythonosc import dispatcher, osc_server -_SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00" b",i\x00\x00" b"\x00\x00\x00\x04" +_SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00,i\x00\x00\x00\x00\x00\x04" # Regression test for a datagram that should NOT be stripped, ever... _SIMPLE_PARAM_INT_9 = b"/debug\x00\x00,i\x00\x00\x00\x00\x00\t" diff --git a/pythonosc/test/test_osc_tcp_server.py b/pythonosc/test/test_osc_tcp_server.py index dd1d1d8..0849803 100644 --- a/pythonosc/test/test_osc_tcp_server.py +++ b/pythonosc/test/test_osc_tcp_server.py @@ -5,7 +5,7 @@ from pythonosc import dispatcher, osc_tcp_server from pythonosc.slip import END -_SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00" b",i\x00\x00" b"\x00\x00\x00\x04" +_SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00,i\x00\x00\x00\x00\x00\x04" LEN_SIMPLE_PARAM_INT_MSG = struct.pack("!I", len(_SIMPLE_PARAM_INT_MSG)) _SIMPLE_PARAM_INT_MSG_1_1 = END + _SIMPLE_PARAM_INT_MSG + END diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index e540b9e..cd124d9 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -99,7 +99,6 @@ def send_message(self, address: str, value: ArgValue) -> None: value: One or more arguments to be added to the message """ builder = OscMessageBuilder(address=address) - values: ArgValue if value is None: pass elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d484603 --- /dev/null +++ b/uv.lock @@ -0,0 +1,482 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "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.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.0" +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/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, +] + +[[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.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "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-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-osc" +version = "1.9.3" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +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" }, +] From bf0d878bddb7a7acb3121d1fe7e03d0ea7d68cbc Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 17:24:01 +0200 Subject: [PATCH 55/69] mention new lint and uv in contributing docs --- CONTRIBUTING.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49c0a37..20f21b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,9 +3,10 @@ Thanks for contributing to this package! Before sending a PR, please make sure you checked the [python test workflow](.github/workflows/python-test.yml) and ran it locally, either using [act](https://nektosact.com) or by executing the workflow actions yourself. TL;DR: -- Format all code with Black -- Provide type annotations with mypy -- Write and run tests with pytest +- Format and lint all code with [ruff](https://docs.astral.sh/ruff/) (use `uv run ruff format .` and `uv run ruff check .`) +- Provide type annotations with mypy (`uv run mypy pythonosc examples`) +- Write and run tests with pytest (`uv run pytest`) +- Use [uv](https://docs.astral.sh/uv/) for package management and environment isolation - If you're adding a new feature, mention it in the [CHANGELOG.md](CHANGELOG.md) file under the _Unreleased_ section Please only send the PR once all of the above is done, thanks! \ No newline at end of file From 6ea1f7ec9bd4597adebc6bc7f962151e36c642e0 Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 17:31:28 +0200 Subject: [PATCH 56/69] use latest versions of actions --- .github/workflows/publish-pypi.yml | 10 ++++++---- .github/workflows/python-test.yml | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 45ffa6f..7121a11 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,5 +1,8 @@ name: Publish to PyPI -on: push +on: + push: + tags: + - 'v*' jobs: build-n-publish: name: Build and publish to PyPI @@ -8,13 +11,12 @@ jobs: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: python-version: "3.x" - name: Build a binary wheel and a source tarball run: uv build - name: Publish distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags') run: uv publish \ No newline at end of file diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 35a73ff..66f43c0 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -16,9 +16,9 @@ jobs: python-version: ['3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -34,18 +34,20 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up uv - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v6 + - name: Set up uv + uses: astral-sh/setup-uv@v7 + - name: Run ruff format run: uv run ruff format --check . check-types-published: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up uv - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v6 + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: python-version: '3.12' - name: Build package From 4cb58e23e877d523f793583471cfd6a822bd467e Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 17:38:06 +0200 Subject: [PATCH 57/69] fix syntax error in workflow --- .github/workflows/publish-pypi.yml | 34 ++++++++++++++++-------------- .github/workflows/python-test.yml | 27 ++++++++++++------------ 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 7121a11..f46219f 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,22 +1,24 @@ name: Publish to PyPI + on: push: tags: - 'v*' + jobs: - build-n-publish: - name: Build and publish to PyPI - runs-on: ubuntu-latest - permissions: - # IMPORTANT: this permission is mandatory for trusted publishing - id-token: write - steps: - - uses: actions/checkout@v6 - - name: Set up uv - uses: astral-sh/setup-uv@v7 - with: - python-version: "3.x" - - name: Build a binary wheel and a source tarball - run: uv build - - name: Publish distribution 📦 to PyPI - run: uv publish \ No newline at end of file + build-n-publish: + name: Build and publish to PyPI + runs-on: ubuntu-latest + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: + - uses: actions/checkout@v6 + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.x" + - name: Build a binary wheel and a source tarball + run: uv build + - name: Publish distribution 📦 to PyPI + run: uv publish diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 66f43c0..c23e243 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -31,15 +31,15 @@ jobs: run: uv run mypy pythonosc examples - name: Test with pytest run: uv run pytest + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up uv uses: astral-sh/setup-uv@v7 - - - name: Run ruff format - run: uv run ruff format --check . + - name: Run ruff format + run: uv run ruff format --check . check-types-published: runs-on: ubuntu-latest @@ -47,14 +47,13 @@ jobs: - uses: actions/checkout@v6 - name: Set up uv uses: astral-sh/setup-uv@v7 - - with: - python-version: '3.12' - - name: Build package - run: uv build - - name: Verify package installation and types - run: | - temp=$(mktemp -d) - uv run --with mypy --with ./dist/*.whl --no-project -- python -c "import pythonosc" - echo 'import pythonosc' > $temp/demo.py - uv run --with mypy --with ./dist/*.whl --no-project -- mypy $temp/demo.py + with: + python-version: '3.12' + - name: Build package + run: uv build + - name: Verify package installation and types + run: | + temp=$(mktemp -d) + uv run --with mypy --with ./dist/*.whl --no-project -- python -c "import pythonosc" + echo 'import pythonosc' > $temp/demo.py + uv run --with mypy --with ./dist/*.whl --no-project -- mypy $temp/demo.py From 1e49edb65e80f08209e79e4d07af8dc485e6b87b Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 20:55:09 +0200 Subject: [PATCH 58/69] fix mypy warnings --- pythonosc/osc_message_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 8efe9b2..7692cd5 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -195,6 +195,7 @@ def build(self) -> osc_message.OscMessage: def build_msg(address: str, value: ArgValue = "") -> osc_message.OscMessage: builder = OscMessageBuilder(address=address) + values: Iterable[Any] if value == "": values = [] elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): From 08e006e56eb6f0410c34bda1e9b4f9674c1dd8af Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 20:58:59 +0200 Subject: [PATCH 59/69] bump version to 1.10.0 --- CHANGELOG.md | 9 ++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a9d9b..6c01b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [Unreleased] +## Unreleased + + + +## [1.10.0] - Added TCPDispatchClient to tcp_client - Fixed TPC dispatcher type annotations +- Fixed regexp matching +- Fixed timestamp parsing > 24h +- Switched to uv ## [1.9.3] diff --git a/pyproject.toml b/pyproject.toml index 04814ca..9e0ff71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "python-osc" -version = "1.9.3" +version = "1.10.0" description = "Open Sound Control server and client implementations in pure Python" readme = "README.rst" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index d484603..208619b 100644 --- a/uv.lock +++ b/uv.lock @@ -372,7 +372,7 @@ wheels = [ [[package]] name = "python-osc" -version = "1.9.3" +version = "1.10.0" source = { editable = "." } [package.dev-dependencies] From a154b9e7358e16e61acdcb1ea3d45f206f04bccb Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 21:01:06 +0200 Subject: [PATCH 60/69] fix: use concrete python version in publish workflow --- .github/workflows/publish-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f46219f..2d5b734 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,7 +17,7 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@v7 with: - python-version: "3.x" + python-version: "3.12" - name: Build a binary wheel and a source tarball run: uv build - name: Publish distribution 📦 to PyPI From 38e07386f524f64aff19662e3286ca7b8bb274bf Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 21:26:24 +0200 Subject: [PATCH 61/69] better type annotations for sending messages. Closes #171 --- pythonosc/osc_message_builder.py | 10 ++++++++-- pythonosc/tcp_client.py | 10 +++++++--- pythonosc/udp_client.py | 4 +++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 7692cd5..63d7754 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -5,7 +5,11 @@ from pythonosc import osc_message from pythonosc.parsing import osc_types -ArgValue = Union[str, bytes, bool, int, float, osc_types.MidiPacket, list, None] +# Represents a single OSC argument value. +# Can be a primitive type, a MIDI packet, or a list/tuple for nested OSC arrays. +ArgValue = Union[ + str, bytes, bool, int, float, osc_types.MidiPacket, List[Any], Tuple[Any, ...], None +] class BuildError(Exception): @@ -193,7 +197,9 @@ def build(self) -> osc_message.OscMessage: raise BuildError(f"Could not build the message: {be}") -def build_msg(address: str, value: ArgValue = "") -> osc_message.OscMessage: +def build_msg( + address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" +) -> osc_message.OscMessage: builder = OscMessageBuilder(address=address) values: Iterable[Any] if value == "": diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index 4ca622c..f5fb51c 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -3,7 +3,7 @@ import asyncio import socket import struct -from typing import AsyncGenerator, Generator, List, Union +from typing import AsyncGenerator, Generator, Iterable, List, Union from pythonosc import slip from pythonosc.dispatcher import Dispatcher @@ -104,7 +104,9 @@ class SimpleTCPClient(TCPClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def send_message(self, address: str, value: ArgValue = "") -> None: + def send_message( + self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" + ) -> None: """Build :class:`OscMessage` from arguments and send to server Args: @@ -251,7 +253,9 @@ def __init__( ): super().__init__(address, port, family, mode) - async def send_message(self, address: str, value: ArgValue = "") -> None: + async def send_message( + self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" + ) -> None: """Build :class:`OscMessage` from arguments and send to server Args: diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index cd124d9..24b0b76 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -91,7 +91,9 @@ def receive(self, timeout: int = 30) -> bytes: class SimpleUDPClient(UDPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" - def send_message(self, address: str, value: ArgValue) -> None: + def send_message( + self, address: str, value: Union[ArgValue, Iterable[ArgValue]] + ) -> None: """Build :class:`OscMessage` from arguments and send to server Args: From b83ce430491bb8548bd37b72f7bdbdfefcd8488e Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 21:40:17 +0200 Subject: [PATCH 62/69] use git hooks to automatically run mypy and others --- .pre-commit-config.yaml | 20 +++ CHANGELOG.md | 2 +- CONTRIBUTING.md | 3 +- docs/Makefile | 2 +- docs/server.rst | 2 +- pyproject.toml | 1 + pythonosc/osc_message_builder.py | 2 +- uv.lock | 203 ++++++++++++++++++++++++++++--- 8 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..17ffd7d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.8 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + args: [ --config-file=pyproject.toml ] + files: ^(pythonosc|examples)/ + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c01b07..e8049da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log All notable changes to this project will be documented in this file. - + The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20f21b0..5e1a5e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ TL;DR: - Provide type annotations with mypy (`uv run mypy pythonosc examples`) - Write and run tests with pytest (`uv run pytest`) - Use [uv](https://docs.astral.sh/uv/) for package management and environment isolation +- Install and use [pre-commit](https://pre-commit.com/) to automatically run ruff before each commit (`uv run pre-commit install`) - If you're adding a new feature, mention it in the [CHANGELOG.md](CHANGELOG.md) file under the _Unreleased_ section -Please only send the PR once all of the above is done, thanks! \ No newline at end of file +Please only send the PR once all of the above is done, thanks! diff --git a/docs/Makefile b/docs/Makefile index 298ea9e..5128596 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -16,4 +16,4 @@ help: # 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) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/server.rst b/docs/server.rst index b71eda6..1b83aab 100644 --- a/docs/server.rst +++ b/docs/server.rst @@ -130,4 +130,4 @@ Server Module Documentation .. automodule:: pythonosc.osc_tcp_server :special-members: :members: - :exclude-members: __weakref__ \ No newline at end of file + :exclude-members: __weakref__ diff --git a/pyproject.toml b/pyproject.toml index 9e0ff71..19fc032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "mypy", "ruff", "pytest-cov", + "pre-commit", ] [tool.uv.build-backend] diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 63d7754..a4eb4f8 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -100,7 +100,7 @@ def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: arg_type = self._get_arg_type(arg_value) if isinstance(arg_type, list): self._args.append((self.ARG_TYPE_ARRAY_START, None)) - for v, t in zip(arg_value, arg_type): # type: ignore[arg-type] + for v, t in zip(arg_value, arg_type): # type: ignore[arg-type, var-annotated] self.add_arg(v, t) self._args.append((self.ARG_TYPE_ARRAY_STOP, None)) else: diff --git a/uv.lock b/uv.lock index 208619b..504d367 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -129,6 +138,15 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -141,6 +159,24 @@ 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 = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -302,6 +338,15 @@ 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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -320,6 +365,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -329,6 +383,22 @@ 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 = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -370,6 +440,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" }, +] + [[package]] name = "python-osc" version = "1.10.0" @@ -378,6 +461,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -388,34 +472,99 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" -version = "0.15.8" +version = "0.15.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] @@ -480,3 +629,19 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 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 = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] From ae1e5dd4c931d3f79c0537f79e0a867852b2cb2e Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 21:52:44 +0200 Subject: [PATCH 63/69] add the ability to specify a timeout when constructing osc servers. Closes #170 --- README.rst | 4 +-- pythonosc/osc_server.py | 3 ++ pythonosc/tcp_client.py | 57 +++++++++++++++++++------------ pythonosc/test/test_osc_server.py | 8 +++++ pythonosc/test/test_udp_client.py | 25 ++++++++++++++ pythonosc/udp_client.py | 29 +++++++++++----- 6 files changed, 95 insertions(+), 31 deletions(-) diff --git a/README.rst b/README.rst index a40202f..5a4de72 100644 --- a/README.rst +++ b/README.rst @@ -73,7 +73,7 @@ Simple client help="The port the OSC server is listening on") args = parser.parse_args() - client = udp_client.SimpleUDPClient(args.ip, args.port) + client = udp_client.SimpleUDPClient(args.ip, args.port, timeout=10) for x in range(10): client.send_message("/filter", random.random()) @@ -117,7 +117,7 @@ Simple server dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) server = osc_server.ThreadingOSCUDPServer( - (args.ip, args.port), dispatcher) + (args.ip, args.port), dispatcher, timeout=10) print("Serving on {}".format(server.server_address)) server.serve_forever() diff --git a/pythonosc/osc_server.py b/pythonosc/osc_server.py index aad0162..806bd3a 100644 --- a/pythonosc/osc_server.py +++ b/pythonosc/osc_server.py @@ -63,6 +63,7 @@ def __init__( server_address: Tuple[str, int], dispatcher: Dispatcher, bind_and_activate: bool = True, + timeout: float | None = None, ) -> None: """Initialize @@ -70,9 +71,11 @@ def __init__( server_address: IP and port of server dispatcher: Dispatcher this server will use (optional) bind_and_activate: default=True defines if the server has to start on call of constructor + (optional) timeout: Default timeout in seconds for socket operations """ super().__init__(server_address, _UDPHandler, bind_and_activate) self._dispatcher = dispatcher + self.timeout = timeout def verify_request( self, request: _RequestType, client_address: _AddressType diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index f5fb51c..4d4d858 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -22,6 +22,7 @@ def __init__( port: int, family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1, + timeout: float | None = 30.0, ) -> None: """Initialize client @@ -29,13 +30,15 @@ def __init__( address: IP address of server port: Port of server family: address family parameter (passed to socket.getaddrinfo) + timeout: Default timeout in seconds for socket operations """ self.address = address self.port = port self.family = family self.mode = mode + self._timeout = timeout self.socket = socket.socket(self.family, socket.SOCK_STREAM) - self.socket.settimeout(30) + self.socket.settimeout(timeout) self.socket.connect((address, port)) def __enter__(self): @@ -56,12 +59,13 @@ def send(self, content: Union[OscMessage, OscBundle]) -> None: b = struct.pack("!I", len(content.dgram)) self.socket.sendall(b + content.dgram) - def receive(self, timeout: int = 30) -> List[bytes]: - self.socket.settimeout(timeout) + def receive(self, timeout: float | None = None) -> List[bytes]: + effective_timeout = timeout if timeout is not None else self._timeout + self.socket.settimeout(effective_timeout) if self.mode == MODE_1_1: try: buf = self.socket.recv(4096) - except TimeoutError: + except (TimeoutError, socket.timeout): return [] if not buf: return [] @@ -69,7 +73,7 @@ def receive(self, timeout: int = 30) -> List[bytes]: while buf[-1] != 192: try: newbuf = self.socket.recv(4096) - except TimeoutError: + except (TimeoutError, socket.timeout): break if not newbuf: # Maybe should raise an exception here? @@ -80,13 +84,13 @@ def receive(self, timeout: int = 30) -> List[bytes]: buf = b"" try: lengthbuf = self.socket.recv(4) - except TimeoutError: + except (TimeoutError, socket.timeout): return [] (length,) = struct.unpack("!I", lengthbuf) while length > 0: try: newbuf = self.socket.recv(length) - except TimeoutError: + except (TimeoutError, socket.timeout): return [] if not newbuf: return [] @@ -116,7 +120,7 @@ def send_message( msg = build_msg(address, value) return self.send(msg) - def get_messages(self, timeout: int = 30) -> Generator: + def get_messages(self, timeout: float | None = None) -> Generator: r = self.receive(timeout) while r: for m in r: @@ -129,7 +133,7 @@ class TCPDispatchClient(SimpleTCPClient): dispatcher = Dispatcher() - def handle_messages(self, timeout_sec: int = 30) -> None: + def handle_messages(self, timeout_sec: float | None = None) -> None: """Wait :int:`timeout` seconds for a message from the server and process each message with the registered handlers. Continue until a timeout occurs. @@ -152,6 +156,7 @@ def __init__( port: int, family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1, + timeout: float | None = 30.0, ) -> None: """Initialize client @@ -159,11 +164,13 @@ def __init__( address: IP address of server port: Port of server family: address family parameter (passed to socket.getaddrinfo) + timeout: Default timeout in seconds for socket operations """ self.address: str = address self.port: int = port self.mode: str = mode self.family: socket.AddressFamily = family + self._timeout = timeout def __await__(self): async def closure(): @@ -197,19 +204,22 @@ async def send(self, content: Union[OscMessage, OscBundle]) -> None: self.writer.write(b + content.dgram) await self.writer.drain() - async def receive(self, timeout: int = 30) -> List[bytes]: + async def receive(self, timeout: float | None = None) -> List[bytes]: + effective_timeout = timeout if timeout is not None else self._timeout if self.mode == MODE_1_1: try: - buf = await asyncio.wait_for(self.reader.read(4096), timeout) - except TimeoutError: + buf = await asyncio.wait_for(self.reader.read(4096), effective_timeout) + except (TimeoutError, asyncio.TimeoutError): return [] if not buf: return [] # If the last byte is not an END marker there could be more data coming while buf[-1] != 192: try: - newbuf = await asyncio.wait_for(self.reader.read(4096), timeout) - except TimeoutError: + newbuf = await asyncio.wait_for( + self.reader.read(4096), effective_timeout + ) + except (TimeoutError, asyncio.TimeoutError): break if not newbuf: # Maybe should raise an exception here? @@ -219,15 +229,19 @@ async def receive(self, timeout: int = 30) -> List[bytes]: else: buf = b"" try: - lengthbuf = await asyncio.wait_for(self.reader.read(4), timeout) - except TimeoutError: + lengthbuf = await asyncio.wait_for( + self.reader.read(4), effective_timeout + ) + except (TimeoutError, asyncio.TimeoutError): return [] (length,) = struct.unpack("!I", lengthbuf) while length > 0: try: - newbuf = await asyncio.wait_for(self.reader.read(length), timeout) - except TimeoutError: + newbuf = await asyncio.wait_for( + self.reader.read(length), effective_timeout + ) + except (TimeoutError, asyncio.TimeoutError): return [] if not newbuf: return [] @@ -250,8 +264,9 @@ def __init__( port: int, family: socket.AddressFamily = socket.AF_INET, mode: str = MODE_1_1, + timeout: float | None = 30.0, ): - super().__init__(address, port, family, mode) + super().__init__(address, port, family, mode, timeout) async def send_message( self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" @@ -265,7 +280,7 @@ async def send_message( msg = build_msg(address, value) return await self.send(msg) - async def get_messages(self, timeout: int = 30) -> AsyncGenerator: + async def get_messages(self, timeout: float | None = None) -> AsyncGenerator: r = await self.receive(timeout) while r: for m in r: @@ -278,7 +293,7 @@ class AsyncDispatchTCPClient(AsyncTCPClient): dispatcher = Dispatcher() - async def handle_messages(self, timeout: int = 30) -> None: + async def handle_messages(self, timeout: float | None = None) -> None: """Wait :int:`timeout` seconds for a message from the server and process each message with the registered handlers. Continue until a timeout occurs. diff --git a/pythonosc/test/test_osc_server.py b/pythonosc/test/test_osc_server.py index 342bd08..d82d214 100644 --- a/pythonosc/test/test_osc_server.py +++ b/pythonosc/test/test_osc_server.py @@ -112,5 +112,13 @@ def respond(*args, **kwargs): ) +class TestOscUdpServer(unittest.TestCase): + @unittest.mock.patch("socket.socket") + def test_init_timeout(self, mock_socket_ctor): + dispatcher = unittest.mock.Mock() + server = osc_server.OSCUDPServer(("127.0.0.1", 0), dispatcher, timeout=10.0) + self.assertEqual(server.timeout, 10.0) + + if __name__ == "__main__": unittest.main() diff --git a/pythonosc/test/test_udp_client.py b/pythonosc/test/test_udp_client.py index c7ee32f..3a9ef5a 100644 --- a/pythonosc/test/test_udp_client.py +++ b/pythonosc/test/test_udp_client.py @@ -64,5 +64,30 @@ def test_context_manager(self, mock_socket_ctor): self.assertTrue(mock_socket.close.called) +class TestUdpClientTimeout(unittest.TestCase): + @mock.patch("socket.socket") + def test_init_timeout(self, mock_socket_ctor): + mock_socket = mock_socket_ctor.return_value + client = udp_client.UDPClient("::1", 31337, timeout=10.0) + self.assertEqual(client._timeout, 10.0) + mock_socket.settimeout.assert_any_call(10.0) + + @mock.patch("socket.socket") + def test_receive_default_timeout(self, mock_socket_ctor): + mock_socket = mock_socket_ctor.return_value + client = udp_client.UDPClient("::1", 31337, timeout=10.0) + mock_socket.recv.return_value = b"data" + client.receive() + mock_socket.settimeout.assert_called_with(10.0) + + @mock.patch("socket.socket") + def test_receive_override_timeout(self, mock_socket_ctor): + mock_socket = mock_socket_ctor.return_value + client = udp_client.UDPClient("::1", 31337, timeout=10.0) + mock_socket.recv.return_value = b"data" + client.receive(timeout=5.0) + mock_socket.settimeout.assert_called_with(5.0) + + if __name__ == "__main__": unittest.main() diff --git a/pythonosc/udp_client.py b/pythonosc/udp_client.py index 24b0b76..6c7e6c3 100644 --- a/pythonosc/udp_client.py +++ b/pythonosc/udp_client.py @@ -25,6 +25,7 @@ def __init__( port: int, allow_broadcast: bool = False, family: socket.AddressFamily = socket.AF_UNSPEC, + timeout: float | None = None, ) -> None: """Initialize client @@ -36,6 +37,7 @@ def __init__( port: Port of server allow_broadcast: Allow for broadcast transmissions family: address family parameter (passed to socket.getaddrinfo) + timeout: Default timeout in seconds for socket operations """ for addr in socket.getaddrinfo( @@ -50,6 +52,10 @@ def __init__( break self._sock.setblocking(False) + if timeout is not None: + self._sock.settimeout(timeout) + self._timeout = timeout + if allow_broadcast: self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) self._address = address @@ -75,16 +81,21 @@ def send(self, content: Union[OscMessage, OscBundle]) -> None: """ self._sock.sendto(content.dgram, (self._address, self._port)) - def receive(self, timeout: int = 30) -> bytes: + def receive(self, timeout: float | None = None) -> bytes: """Wait :int:`timeout` seconds for a message an return the raw bytes Args: - timeout: Number of seconds to wait for a message + timeout: Number of seconds to wait for a message. + If None, uses the default timeout set in __init__. """ - self._sock.settimeout(timeout) + if timeout is not None: + self._sock.settimeout(timeout) + elif self._timeout is not None: + self._sock.settimeout(self._timeout) + try: return self._sock.recv(4096) - except TimeoutError: + except (TimeoutError, socket.timeout, BlockingIOError): return b"" @@ -111,11 +122,12 @@ def send_message( msg = builder.build() self.send(msg) - def get_messages(self, timeout: int = 30) -> Generator: + def get_messages(self, timeout: float | None = None) -> Generator: """Wait :int:`timeout` seconds for a message from the server and convert it to a :class:`OscMessage` Args: - timeout: Time in seconds to wait for a message + timeout: Time in seconds to wait for a message. + If None, uses the default timeout set in __init__. """ msg = self.receive(timeout) while msg: @@ -128,12 +140,13 @@ class DispatchClient(SimpleUDPClient): dispatcher = Dispatcher() - def handle_messages(self, timeout: int = 30) -> None: + def handle_messages(self, timeout: float | None = None) -> None: """Wait :int:`timeout` seconds for a message from the server and process each message with the registered handlers. Continue until a timeout occurs. Args: - timeout: Time in seconds to wait for a message + timeout: Time in seconds to wait for a message. + If None, uses the default timeout set in __init__. """ msg = self.receive(timeout) while msg: From 66ef6ac61bf5890737178931e0479a5e126098fb Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 21:59:00 +0200 Subject: [PATCH 64/69] make dispatcher match partial addreses when * is in the middle of them and force end to match. Fixes #180 --- pythonosc/dispatcher.py | 4 ++-- pythonosc/test/test_dispatcher.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index ea7f360..7affe76 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -240,8 +240,8 @@ def handlers_for_address( matched = False for addr, handlers in self._map.items(): if patterncompiled.match(addr) or ( - ("*" in addr) - and re.match(addr.replace("*", "[^/]*?/*"), address_pattern) + "*" in addr + and re.match(addr.replace("*", ".*?") + "$", address_pattern) ): yield from handlers matched = True diff --git a/pythonosc/test/test_dispatcher.py b/pythonosc/test/test_dispatcher.py index 523facd..15047f8 100644 --- a/pythonosc/test/test_dispatcher.py +++ b/pythonosc/test/test_dispatcher.py @@ -181,6 +181,17 @@ def dummyhandler(): with self.assertRaises(ValueError): self.dispatcher.unmap("/unmap/exception", handlerobj) + def test_handlers_for_address_wildcard_no_partial_match(self): + self.dispatcher.map("/qwer/*/zxcv", 1) + # Should not match + handlers = list( + self.dispatcher.handlers_for_address("/qwer/whatever/zxcvsomethingmore") + ) + self.assertEqual(len(handlers), 0) + # Should match + handlers = list(self.dispatcher.handlers_for_address("/qwer/whatever/zxcv")) + self.assertEqual(len(handlers), 1) + if __name__ == "__main__": unittest.main() From 7581a23adbc39a678a2e484f3544a3eef7844e23 Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 22:15:55 +0200 Subject: [PATCH 65/69] support ipv6 in OSCUDPServer --- pythonosc/osc_message_builder.py | 12 +++++++----- pythonosc/osc_server.py | 20 ++++++++++++++++++++ pythonosc/osc_tcp_server.py | 20 ++++++++++++++++++++ pythonosc/test/test_osc_server.py | 16 ++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index a4eb4f8..0604b84 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -72,9 +72,9 @@ def args(self) -> List[Tuple[str, Union[ArgValue, None]]]: """Returns the (type, value) arguments list of this message.""" return self._args - def _valid_type(self, arg_type: str) -> bool: - if arg_type in self._SUPPORTED_ARG_TYPES: - return True + def _valid_type(self, arg_type: Union[str, List[Any]]) -> bool: + if isinstance(arg_type, str): + return arg_type in self._SUPPORTED_ARG_TYPES elif isinstance(arg_type, list): for sub_type in arg_type: if not self._valid_type(sub_type): @@ -82,7 +82,9 @@ def _valid_type(self, arg_type: str) -> bool: return True return False - def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: + def add_arg( + self, arg_value: ArgValue, arg_type: Optional[Union[str, List[Any]]] = None + ) -> None: """Add a typed argument to this message. Args: @@ -100,7 +102,7 @@ def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: arg_type = self._get_arg_type(arg_value) if isinstance(arg_type, list): self._args.append((self.ARG_TYPE_ARRAY_START, None)) - for v, t in zip(arg_value, arg_type): # type: ignore[arg-type, var-annotated] + for v, t in zip(arg_value, arg_type): # type: ignore[arg-type] self.add_arg(v, t) self._args.append((self.ARG_TYPE_ARRAY_STOP, None)) else: diff --git a/pythonosc/osc_server.py b/pythonosc/osc_server.py index 806bd3a..0122a2c 100644 --- a/pythonosc/osc_server.py +++ b/pythonosc/osc_server.py @@ -2,6 +2,7 @@ import asyncio import os +import socket import socketserver from socket import socket as _socket from typing import Any, Coroutine, Tuple, Union, cast @@ -64,6 +65,7 @@ def __init__( dispatcher: Dispatcher, bind_and_activate: bool = True, timeout: float | None = None, + family: socket.AddressFamily | None = None, ) -> None: """Initialize @@ -72,7 +74,25 @@ def __init__( dispatcher: Dispatcher this server will use (optional) bind_and_activate: default=True defines if the server has to start on call of constructor (optional) timeout: Default timeout in seconds for socket operations + (optional) family: socket.AF_INET or socket.AF_INET6. If None, it will be inferred from server_address. """ + if family is not None: + self.address_family = family + else: + # Try to infer address family from server_address + try: + infos = socket.getaddrinfo( + server_address[0], + server_address[1], + type=socket.SOCK_DGRAM, + family=socket.AF_UNSPEC, + ) + if infos: + self.address_family = infos[0][0] + except (socket.gaierror, IndexError): + # Fallback to default if resolution fails + pass + super().__init__(server_address, _UDPHandler, bind_and_activate) self._dispatcher = dispatcher self.timeout = timeout diff --git a/pythonosc/osc_tcp_server.py b/pythonosc/osc_tcp_server.py index aec674f..f3e2778 100644 --- a/pythonosc/osc_tcp_server.py +++ b/pythonosc/osc_tcp_server.py @@ -35,6 +35,7 @@ import asyncio import logging import os +import socket import socketserver import struct from typing import List, Tuple @@ -146,11 +147,30 @@ def __init__( server_address: Tuple[str | bytes | bytearray, int], dispatcher: Dispatcher, mode: str = MODE_1_1, + family: socket.AddressFamily | None = None, ): self.request_queue_size = 300 self.mode = mode if mode not in [MODE_1_0, MODE_1_1]: raise ValueError("OSC Mode must be '1.0' or '1.1'") + + if family is not None: + self.address_family = family + elif isinstance(server_address[0], str): + # Try to infer address family from server_address + try: + infos = socket.getaddrinfo( + server_address[0], + server_address[1], + type=socket.SOCK_STREAM, + family=socket.AF_UNSPEC, + ) + if infos: + self.address_family = infos[0][0] + except (socket.gaierror, IndexError): + # Fallback to default if resolution fails + pass + if self.mode == MODE_1_0: super().__init__(server_address, _TCPHandler1_0) else: diff --git a/pythonosc/test/test_osc_server.py b/pythonosc/test/test_osc_server.py index d82d214..42a2807 100644 --- a/pythonosc/test/test_osc_server.py +++ b/pythonosc/test/test_osc_server.py @@ -1,3 +1,4 @@ +import socket import unittest import unittest.mock @@ -119,6 +120,21 @@ def test_init_timeout(self, mock_socket_ctor): server = osc_server.OSCUDPServer(("127.0.0.1", 0), dispatcher, timeout=10.0) self.assertEqual(server.timeout, 10.0) + @unittest.mock.patch("socket.socket") + def test_init_family_inference_ipv4(self, mock_socket_ctor): + dispatcher = unittest.mock.Mock() + server = osc_server.OSCUDPServer(("127.0.0.1", 0), dispatcher) + self.assertEqual(server.address_family, socket.AF_INET) + + @unittest.mock.patch("socket.socket") + def test_init_family_inference_ipv6(self, mock_socket_ctor): + dispatcher = unittest.mock.Mock() + # Mock getaddrinfo to return IPv6 for this test to be environment-independent + with unittest.mock.patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [(socket.AF_INET6, None, None, None, None)] + server = osc_server.OSCUDPServer(("::1", 0), dispatcher) + self.assertEqual(server.address_family, socket.AF_INET6) + if __name__ == "__main__": unittest.main() From a88f33b959ec4561e65745bcf5df6c66a939660c Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 22:40:00 +0200 Subject: [PATCH 66/69] better asyncio handling --- pythonosc/dispatcher.py | 101 ++++++++++++++++++------------ pythonosc/test/test_dispatcher.py | 51 ++++++++++++++- 2 files changed, 110 insertions(+), 42 deletions(-) diff --git a/pythonosc/dispatcher.py b/pythonosc/dispatcher.py index 7affe76..30ec6c6 100644 --- a/pythonosc/dispatcher.py +++ b/pythonosc/dispatcher.py @@ -1,5 +1,6 @@ """Maps OSC addresses to handler functions""" +import asyncio import collections import inspect import logging @@ -80,6 +81,46 @@ def invoke( else: return self.callback(message.address, *message) + async def async_invoke( + self, client_address: Tuple[str, int], message: OscMessage + ) -> Union[None, AnyStr, Tuple[AnyStr, ArgValue]]: + """Invokes the associated callback function (asynchronously) + + Args: + client_address: Address match that causes the invocation + message: Message causing invocation + Returns: + The result of the handler function can be None, a string OSC address, or a tuple of the OSC address + and arguments. + """ + cb = self.callback + is_async = inspect.iscoroutinefunction(cb) + + if self.needs_reply_address: + if self.args: + if is_async: + return await cb( + client_address, message.address, self.args, *message + ) + else: + return cb(client_address, message.address, self.args, *message) + else: + if is_async: + return await cb(client_address, message.address, *message) + else: + return cb(client_address, message.address, *message) + else: + if self.args: + if is_async: + return await cb(message.address, self.args, *message) + else: + return cb(message.address, self.args, *message) + else: + if is_async: + return await cb(message.address, *message) + else: + return cb(message.address, *message) + class Dispatcher(object): """Maps Handlers to OSC addresses and dispatches messages to the handler on matched addresses @@ -87,9 +128,20 @@ class Dispatcher(object): Maps OSC addresses to handler functions and invokes the correct handler when a message comes in. """ - def __init__(self) -> None: + def __init__(self, strict_timing: bool = True) -> None: + """Initialize the dispatcher. + + Args: + strict_timing: Whether to automatically schedule messages with future timetags. + If True (default), the dispatcher will wait (using sleep) until the specified + timetag before invoking handlers. + If False, messages are dispatched immediately regardless of their timetag. + Disabling this can prevent memory/thread accumulation issues when receiving + many future-dated messages. + """ self._map: DefaultDict[str, List[Handler]] = collections.defaultdict(list) self._default_handler: Optional[Handler] = None + self._strict_timing = strict_timing def map( self, @@ -272,7 +324,7 @@ def call_handlers_for_packet( if not handlers: continue # If the message is to be handled later, then so be it. - if timed_msg.time > now: + if self._strict_timing and timed_msg.time > now: time.sleep(timed_msg.time - now) for handler in handlers: result = handler.invoke(client_address, timed_msg.message) @@ -309,46 +361,13 @@ async def async_call_handlers_for_packet( if not handlers: continue # If the message is to be handled later, then so be it. - if timed_msg.time > now: - time.sleep(timed_msg.time - now) + if self._strict_timing and timed_msg.time > now: + await asyncio.sleep(timed_msg.time - now) for handler in handlers: - if inspect.iscoroutinefunction(handler.callback): - if handler.needs_reply_address: - result = await handler.callback( - client_address, - timed_msg.message.address, - handler.args, - *timed_msg.message, - ) - elif handler.args: - result = await handler.callback( - timed_msg.message.address, - handler.args, - *timed_msg.message, - ) - else: - result = await handler.callback( - timed_msg.message.address, *timed_msg.message - ) - else: - if handler.needs_reply_address: - result = handler.callback( - client_address, - timed_msg.message.address, - handler.args, - *timed_msg.message, - ) - elif handler.args: - result = handler.callback( - timed_msg.message.address, - handler.args, - *timed_msg.message, - ) - else: - result = handler.callback( - timed_msg.message.address, *timed_msg.message - ) - if result: + result = await handler.async_invoke( + client_address, timed_msg.message + ) + if result is not None: results.append(result) except osc_packet.ParseError: pass diff --git a/pythonosc/test/test_dispatcher.py b/pythonosc/test/test_dispatcher.py index 15047f8..d456f10 100644 --- a/pythonosc/test/test_dispatcher.py +++ b/pythonosc/test/test_dispatcher.py @@ -3,7 +3,7 @@ from pythonosc.dispatcher import Dispatcher, Handler -class TestDispatcher(unittest.TestCase): +class TestDispatcher(unittest.IsolatedAsyncioTestCase): def setUp(self): super().setUp() self.dispatcher = Dispatcher() @@ -192,6 +192,55 @@ def test_handlers_for_address_wildcard_no_partial_match(self): handlers = list(self.dispatcher.handlers_for_address("/qwer/whatever/zxcv")) self.assertEqual(len(handlers), 1) + def test_strict_timing_disabled(self): + # Disable strict timing + dispatcher = Dispatcher(strict_timing=False) + + callback_called = False + + def handler(address, *args): + nonlocal callback_called + callback_called = True + + dispatcher.map("/test", handler) + + # Create a message with a future timestamp (1 hour from now) + # We'll use OscPacket to simulate a bundle with a future timestamp + # But for simple unit test, we can just check if it sleeps + # Since we can't easily mock time.sleep across the dispatcher without more effort, + # we'll just verify the logic exists. + self.assertFalse(dispatcher._strict_timing) + + async def test_async_call_handlers_for_packet(self): + dispatcher = Dispatcher() + + sync_called = False + + def sync_handler(address, *args): + nonlocal sync_called + sync_called = True + + async_called = False + + async def async_handler(address, *args): + nonlocal async_called + async_called = True + + dispatcher.map("/sync", sync_handler) + dispatcher.map("/async", async_handler) + + # Dispatch sync handler + dgram_sync = b"/sync\x00\x00\x00,\x00\x00\x00" + await dispatcher.async_call_handlers_for_packet(dgram_sync, ("127.0.0.1", 1234)) + self.assertTrue(sync_called) + + # Dispatch async handler + dgram_async = b"/async\x00\x00,\x00\x00\x00" + await dispatcher.async_call_handlers_for_packet( + dgram_async, ("127.0.0.1", 1234) + ) + self.assertTrue(async_called) + if __name__ == "__main__": unittest.main() From e91ace85525bd25858a5cea77528d0159cb5ff14 Mon Sep 17 00:00:00 2001 From: tmu Date: Thu, 2 Apr 2026 22:44:27 +0200 Subject: [PATCH 67/69] bump patch versin for release --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8049da..0a4f292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## Unreleased +## [1.10.2] +- Better asyncio handling, ability to ignore timetags for future messages ## [1.10.0] diff --git a/pyproject.toml b/pyproject.toml index 19fc032..83a5a4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "python-osc" -version = "1.10.0" +version = "1.10.2" description = "Open Sound Control server and client implementations in pure Python" readme = "README.rst" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 504d367..d2889c8 100644 --- a/uv.lock +++ b/uv.lock @@ -455,7 +455,7 @@ wheels = [ [[package]] name = "python-osc" -version = "1.10.0" +version = "1.10.2" source = { editable = "." } [package.dev-dependencies] From dc007554ce8152aeba729c66defd01b3922d46d0 Mon Sep 17 00:00:00 2001 From: tmu Date: Sat, 9 May 2026 09:29:06 +0200 Subject: [PATCH 68/69] upgrade uv deps --- uv.lock | 383 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 213 insertions(+), 170 deletions(-) diff --git a/uv.lock b/uv.lock index d2889c8..17ade04 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,48 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, + { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, + { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, + { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, + { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, + { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, +] [[package]] name = "cfgv" @@ -161,20 +203,20 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] name = "identify" -version = "2.6.18" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] @@ -188,145 +230,146 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" +version = "0.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, - { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, - { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, - { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, - { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, - { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, - { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, - { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, - { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, - { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/18/827e5c1262a88c2602e86f99aee0f288ffea3280dbd2ff448858ef9dc6e9/librt-0.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dc99f9642100b86e5f6bb14cdc9970009e31a9ef7d64df6704b7018451524a3", size = 76461, upload-time = "2026-05-05T16:29:00.422Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/54254e30287f5a5abec6fef22d976987476e966be5fdff51fe8c2d5d73d1/librt-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8298cedfcfaff3790000bd057aaaa3df1b0ab54cf7b48eeab16184cbb1bc66b9", size = 79740, upload-time = "2026-05-05T16:29:01.926Z" }, + { url = "https://files.pythonhosted.org/packages/4c/20/e93264b52113669d98d3b63ff94d4ce0c4dd49ae0503f1788440a884e5f0/librt-0.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7dbe312dbf76468255b79a7ba311236fde620f2f7055fc09d421e31340314e", size = 243472, upload-time = "2026-05-05T16:29:03.373Z" }, + { url = "https://files.pythonhosted.org/packages/35/ad/34a5141178e8b18a4cfa45d1a0d523c84397e2abd5d06fea2d846da687e8/librt-0.10.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:56ed90c48c19249012dadfd79a1bc13bd5168ea60a70722d330a3a600c0b1852", size = 232073, upload-time = "2026-05-05T16:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/97/1f/67240e910cd9f9ab1498c1470738345fc29dce5dc9719db1e0e09d1e861f/librt-0.10.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d74ca0f4b2b09c117f913d4df01f6b934dff8a271096b35167d5264a31649f0", size = 256956, upload-time = "2026-05-05T16:29:06.516Z" }, + { url = "https://files.pythonhosted.org/packages/22/50/3a2b3482c27d607f6e8216d913c6bc592b9a2141d96990309452340a78e3/librt-0.10.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8eb2daa9375f93c0e55ff5e44a4bbe98f39e5fe52e1abf9c97acb67743b61bf8", size = 250593, upload-time = "2026-05-05T16:29:08.324Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1c/07dba133d79f93322fa17514062f1a2a50d6bdfb7baec4acf78193d7fad1/librt-0.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7b09b90e634e6dff57978cd358070046071e2b120501f10787aeb35425f504f6", size = 263582, upload-time = "2026-05-05T16:29:09.866Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/033f2c6d6ab0b48f15f02e5bf065521b11a51922806017f8b6274df30d69/librt-0.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2cf22fd379d60c739b800d4295ed34045f8b04aa8df9c12bd2f8f43f7fe672b7", size = 259307, upload-time = "2026-05-05T16:29:11.675Z" }, + { url = "https://files.pythonhosted.org/packages/6e/10/679046cd75d5a52c0104c890d8f69574ef4e619c683e59c15584d03a2457/librt-0.10.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:74c798793fcf29a84d442278ebe0bb1fff79fe58ac4106eeff7019cbba861423", size = 257342, upload-time = "2026-05-05T16:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d5/dbaac9c0884f78a53dda22b9ec92bb788e1400e762ed7623fa96928c8da5/librt-0.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc4f1573401e8dbe6c26511fe027620b0fb30ae9a7ab814e02e510626b8b5f9c", size = 280141, upload-time = "2026-05-05T16:29:14.922Z" }, + { url = "https://files.pythonhosted.org/packages/cc/81/71f18cf8eb340d9fda011498870910f6a8697aeb50833005d3d8107653fd/librt-0.10.0-cp310-cp310-win32.whl", hash = "sha256:e1428275f5fe3d4db6822e58d8b005a5b28ffca55e8433ebc051247fbe46429f", size = 62257, upload-time = "2026-05-05T16:29:16.226Z" }, + { url = "https://files.pythonhosted.org/packages/df/52/6bcebc2f870c4836bcb372be885fae7f17a1d25037d3a8250ef79fbe0124/librt-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:0708e9408f585b0f065081680583a577652099680ccf820c7538904322b679c3", size = 70321, upload-time = "2026-05-05T16:29:17.41Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a3/1472717d2325adacc8d335ba2e4078015c09d75b599f3cf48e967b3d306e/librt-0.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01b4500ca3a625450c032a9142a8e843923ce263fa8a92ad1b38927cabe2fe72", size = 76045, upload-time = "2026-05-05T16:29:18.731Z" }, + { url = "https://files.pythonhosted.org/packages/a6/31/bfe32355d4b369aef3d7aa442df663bb5558c2ffa2de286cb2956346bc24/librt-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6b7e42d1b3e300d20bfc87e72ffd62f0a92a2cb3c35f7bf90df90c9d2a49f74c", size = 79466, upload-time = "2026-05-05T16:29:20.052Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f1/83f8a2c715ba2cac9b7387a5a5cea25f717f7184320cfe48b36bed9c58e9/librt-0.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8ef7b8c61ce3a1b597cd3e15348ff1574325165c2e7ce09a718154cde2a7950", size = 242283, upload-time = "2026-05-05T16:29:21.596Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/c3a4ce94857f0004a542f86662806383611858f522722db58efaec0a1472/librt-0.10.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e73c84f72d1fa0d6eaa7a1930b436ba8d2c90c58d77bfabb09995a69ad35f6c0", size = 230735, upload-time = "2026-05-05T16:29:23.335Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/e962bb26c7728eb7b3a69e490d0c800fd9968a6970e390c1f18ddb56093d/librt-0.10.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9728cb98713bd862fb8f4fd6a642d1896c86058a41d77c70f3d5cee75e725275", size = 256606, upload-time = "2026-05-05T16:29:24.91Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/4e46a707b1ecc993fd691071623b9beab89703a63bd21cc7807e06c28209/librt-0.10.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:648b7e941d20acd72f9652115e0e53facd98156d61f9ebf7a812bdef8bdccea9", size = 249739, upload-time = "2026-05-05T16:29:26.648Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/dc5b7eb294656ad23d4ff4cf8514208d54fe1026b909d726a0dc026689c9/librt-0.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c3e33747c068e86a9007c20fdb777eb5ba8d3d19136d7812f88e69a713041b6f", size = 261414, upload-time = "2026-05-05T16:29:28.702Z" }, + { url = "https://files.pythonhosted.org/packages/58/e4/990ed8d12c7f114ac8f8ccd47f7d9bd9704ef61acfcb1df4a05047da7710/librt-0.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d509c745bf7e77d1107cf05e6abb249dc03fad13eb39f2286a49deedaeb2bcd7", size = 256614, upload-time = "2026-05-05T16:29:30.357Z" }, + { url = "https://files.pythonhosted.org/packages/60/eb/52d2726c7fb22818507dc3cc166c8f36dd4a4b68a7be67f12006ac8777c1/librt-0.10.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:786ad5a15e99d0e0e74f3adbeecc198a5ac58f340be07e984723d1e0074838de", size = 255144, upload-time = "2026-05-05T16:29:32.106Z" }, + { url = "https://files.pythonhosted.org/packages/bc/df/bd5591a78f7531fce4b6eb9962aadc6adc9560a01570442a884b6e554abe/librt-0.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:075582d877a97ee3d8e77bda3689dbe617b14f6469224a2d80b4b6c38e3951aa", size = 279121, upload-time = "2026-05-05T16:29:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/fd/df/7c2b838dfc89a1762dd156d8b0c39848a7a2845d725a50be5a6e021fb8ba/librt-0.10.0-cp311-cp311-win32.whl", hash = "sha256:75ecdc3f5a90065aa2af2e574706c5495adc392520762dcf10b1aa716f0b8090", size = 62593, upload-time = "2026-05-05T16:29:35.152Z" }, + { url = "https://files.pythonhosted.org/packages/91/19/22ff572981049a9d436a083dbea1572d0f5dc068b7353637d2dd9977c8f1/librt-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:b6f6084884131d8a52cb9d7095ff2aa52c1e786d9fdaefab1fb4515415e9e083", size = 70914, upload-time = "2026-05-05T16:29:36.407Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/1697cc64f4a5c7e9bce55e99c6d234a346beaedaefcd1e2ca90dd285f98c/librt-0.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:0140bd62151160047e89b2730cb6f8506cdac5127baa1afb9231e4dd3fe7f681", size = 61176, upload-time = "2026-05-05T16:29:37.62Z" }, + { url = "https://files.pythonhosted.org/packages/12/8e/cbb5b6f6e45e65c10a42449a69eaccc44d73e6a081ea752fbc5221c6dc1c/librt-0.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b4b58a44b407e91f633dafee008de9ddea6aa2a555ed94929c099260910bd0ba", size = 77327, upload-time = "2026-05-05T16:29:38.919Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3d/8233cbee8e99e6a8992f02bfc2dec8d787509566a511d1fde2574ee7473f/librt-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:950b79b11762531bdf45a9df909d2f9a2a8445c70c88665c01d14c8511a27dc5", size = 79971, upload-time = "2026-05-05T16:29:40.96Z" }, + { url = "https://files.pythonhosted.org/packages/87/6f/5264b298cef2b72fc97d2dde56c66181eda35204bf5dcd1ed0c3d0a0a782/librt-0.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4538453f51be197633b425912c150e25b0667252d3741c53e8368176d98d9d37", size = 246559, upload-time = "2026-05-05T16:29:42.701Z" }, + { url = "https://files.pythonhosted.org/packages/07/7b/19b1b859cc60d5f99276cc2b3144d91556c6d1b1e4ebb50359696bebf7a8/librt-0.10.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:70b955f091beac93e994a0b7ec616934f63b3ea5c3d6d7af847562f935aceca7", size = 235216, upload-time = "2026-05-05T16:29:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/6e/56/a2f40717142a8af46289f57874ef914353d8faccd5e4f8e594ab1e16e8c7/librt-0.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:483e685e06b6163728ba6c85d74315176be7190f432ec2a41226e5e14355d5f0", size = 263108, upload-time = "2026-05-05T16:29:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/ca/15c625c3bdc0167c01e04ef8878317e9713f3bfa788438342f7a94c7b22c/librt-0.10.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ac53d946a009d1a38c44a60812708c9458fb2a239a5f630d8e625571386650f", size = 255280, upload-time = "2026-05-05T16:29:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/ba301d571d9e05844e2435b73aba30bee77bb75ce155c9affcfd2173dd03/librt-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc8771c9fcf0ea894ca41fdc2abd83572c2fbda221f232d86e718614e57ff513", size = 268829, upload-time = "2026-05-05T16:29:49.628Z" }, + { url = "https://files.pythonhosted.org/packages/8b/60/af70e135bc1f1fe15dd3894b1e4bbefc7ecdf911749a925a39eb86ceb2a1/librt-0.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:70805dbc5257892ac572f86290a61e3c8d90224ecce1a8b2d1f7ed51965417f4", size = 262051, upload-time = "2026-05-05T16:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/c2/c8236eb8b421bac5a172ba208f965abaa89805da2a3fa112bdf1764caf8f/librt-0.10.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d3b4f300f7bcba6e2ff73fb8bef1898479e9772bfa2682998c636391633ec826", size = 264347, upload-time = "2026-05-05T16:29:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/15b6d32bc25dacd4a60886a683d8128d6219910c122202b995a40dd4f8d2/librt-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:943bc943f92f4fb3408fae62485c6a3ad68ce4f2ee205643a39641525c19a276", size = 286482, upload-time = "2026-05-05T16:29:54.675Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8e/b1b959bacd323eb4360579db992513e1406d1c6ef7edb57b5511fd0666fd/librt-0.10.0-cp312-cp312-win32.whl", hash = "sha256:6065c1a758fba1010b41401013903d3d5d2750eab425ddedd584abac31d0630e", size = 62955, upload-time = "2026-05-05T16:29:56.39Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4c/d4cd6e4b9fc24098e63cc85537d1b6689682aee96809c38f08072067cc2b/librt-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:d788ecbe208ab352dab0e105cc06057bf9a2fc7e58cabb0d751ad9e30062b9e2", size = 71191, upload-time = "2026-05-05T16:29:57.682Z" }, + { url = "https://files.pythonhosted.org/packages/2b/19/8641da1f63d24b92354a492f893c022d6b3a0df44e70c8eff49364613983/librt-0.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:6003d1f295bdba02656dc81308208fc060d0a51d8c0d0a6db70f7f3c57b9ba0a", size = 61432, upload-time = "2026-05-05T16:29:58.971Z" }, + { url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" }, + { url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" }, + { url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" }, + { url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" }, + { url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" }, + { url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" }, + { url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" }, ] [[package]] name = "mypy" -version = "1.20.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { 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/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/dc/7e6d49f04fca40b9dd5c752a51a432ffe67fb45200702bc9eee0cb4bbb26/mypy-2.0.0.tar.gz", hash = "sha256:1a9e3900ac5c40f1fe813506c7739da6e6f0eab2729067ebd94bfb0bbba53532", size = 3869036, upload-time = "2026-05-06T19:26:43.22Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" }, - { url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" }, - { url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" }, - { url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" }, - { url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" }, - { url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" }, - { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" }, - { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" }, - { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" }, - { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, - { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, - { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, - { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, - { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, - { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, - { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, - { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, - { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, - { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, - { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, - { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, - { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, - { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, - { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, - { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, - { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/9983d2d5b5d2dc3677177bcf0fa6b25185ecf750cc0559e02199625a31c5/mypy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:65d6f22d643bccaeb182d41d2a9f0990a05a871673c4ae3f97d4931eca0d2294", size = 14663140, upload-time = "2026-05-06T19:25:59.474Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/b4009c91d3ced13c8f406acf47bbe56365025cd21bf6585cd1e87375a708/mypy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:106650bce72114f43019bf72197296f51c2cd47adfa9d073ea2976c247a404c5", size = 13526733, upload-time = "2026-05-06T19:22:56.425Z" }, + { url = "https://files.pythonhosted.org/packages/f0/99/2403cb0ceeb1552f70e70e779e3d0713b24f84c7ca0e9e14b2b7bc684cf0/mypy-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c734b7eb89a4cc4ec347f8187ffa730e2b59693407bc93dcb878183037f80a17", size = 13951940, upload-time = "2026-05-06T19:24:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f7/4848a14c2667b6eb62841c9aeb7e1f6479613b1ef9a65564fe1f5518a35b/mypy-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd9e60388944d0f1432a2419ab938a78d5658df1d143a7172cfe1a197276cf49", size = 14833983, upload-time = "2026-05-06T19:23:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/ec/28/c51831f9f1c6e46cbce765bd0a18981b84696e40bd1eea14e0a08494af44/mypy-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95e3890666c3be41af7a7179f4872341c08e90c161ba8e7a08a21f9be92c131", size = 15135591, upload-time = "2026-05-06T19:24:32.96Z" }, + { url = "https://files.pythonhosted.org/packages/40/7f/3c25e503a94f9ec18352464551bc6c506dee2bca93c6d0e0b5568eefc269/mypy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8e8709ce1b1046b8aad77a506dd01491157102dd727128c0b374b5025c7d769", size = 10983019, upload-time = "2026-05-06T19:20:30.942Z" }, + { url = "https://files.pythonhosted.org/packages/75/da/5cf833fd3b53fd4b5797e55dc16fb7efab16fddbc7205d49ff65b15d554e/mypy-2.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:0165968759c99ab79dc1a9f8aaec18e93a1bedcf7c13edd70e68dd3d5faf17cb", size = 9914165, upload-time = "2026-05-06T19:21:49.165Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1e/268b81393b81d64683f670680215553e70ae92c55805915b3440080e05e4/mypy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17b7222e9fdfd352e61fb3131da117e55cc465f701ff232f1bd97a02bbad91f", size = 14580849, upload-time = "2026-05-06T19:23:06.567Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/d159a8002d9e5c44e59ece9d641a26956c89be5b6827f819d9a9dc678c65/mypy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0a61adea1a5ffc2d47a4dc4bb180d8103f477fc2a90a1cdcbb168c2cc6caff", size = 13444955, upload-time = "2026-05-06T19:25:11.982Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5d/3b28d5a2799591da0ee5490418e94497eaf5d701e42d8b001b5e17a9b3d6/mypy-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8578f857b519993d065e5805290b71467ebfae772407a5f57e823755e4fdb850", size = 13873124, upload-time = "2026-05-06T19:20:39.684Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/f40f723955617b814d5ddc1154d8938b77aaf6926c2dbf72846e8943a0b7/mypy-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33f668a37a650df60f7b825c1ac61e6baadd4ac3c89519e929badde58d28edf5", size = 14748822, upload-time = "2026-05-06T19:25:30.972Z" }, + { url = "https://files.pythonhosted.org/packages/d6/16/eded971224a483e422a141ffd580c00e1b919df8e529f06d03a4a987878c/mypy-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29ea6da86c8c5e9addd48fa6e624f467341b3814f54ded871b28980468686dea", size = 14992675, upload-time = "2026-05-06T19:23:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6a/1cbd7290f00b4dbaa4c4502e53ac05645ea635e4d1e3dcd42687c2fc39cd/mypy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:904baa0124ebbccf0c7ba94f722cf9186ee30478f5e5b11432ffc8929248ee55", size = 10983628, upload-time = "2026-05-06T19:26:39.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/3f/8caa9bcc2636cd512642050747466b695fa2540d7040544fd7ddb721d671/mypy-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:440165501295e523bf1e5d3e411b62b367b901c65610938e75f0e56ba0462461", size = 9906041, upload-time = "2026-05-06T19:24:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4b/f6cd12ef1eb63be1c342da3e8ca811d2280276177f6de4ef20cb2366d79b/mypy-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:660790551c988e69d8bf7a35c8b4149edeb22f4a339165702be843532e9dcdb5", size = 14756610, upload-time = "2026-05-06T19:26:19.221Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/67d09ca28bee21feaca264b2a680cf2d300bcc2071136ad064928324c843/mypy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a15bf92cd8781f8e72f69ffa7e30d1f434402d065ee1ecd5223ef2ef100f914", size = 13554270, upload-time = "2026-05-06T19:26:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/44718b5c6b1b5a27440ff2effe6a1be0fa2a190c0f4e2e21a83728416f95/mypy-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ff370b43d7def05bbcd2f5267f0bcda72dd6a552ef2ea9375b02d6fe06da270", size = 13924663, upload-time = "2026-05-06T19:21:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2b/bbb9cc5773f946846a7c340097e59bcf84095437dda0d56bb4f6cf1f6541/mypy-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37bd246590a018e5a11703b7b09c39d47ede3df5ba3fa863c5b8590b465beb01", size = 14946862, upload-time = "2026-05-06T19:24:23.023Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/e9318566f443a5130b4ff0ad3367ee6c4c4c49ff083fe5214a7318c18282/mypy-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cce87e92214fac8bf8feb8a680d0c1b6fb748d50e9b57fbb13e4b1d83a3ed19b", size = 15175090, upload-time = "2026-05-06T19:26:28.794Z" }, + { url = "https://files.pythonhosted.org/packages/67/65/2ec28c834f21e164c33bc296a7db538ad50c74f83e517c7a0be95ff6de86/mypy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e19e9cb69b66a4141009d24898259914fa2b71d026de0b46edf9fafdbf4fd46e", size = 11052899, upload-time = "2026-05-06T19:25:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/9e/72/d1ec625cfc9bd101c07a6834ef1f94e820296f8fdbad2eb03f50e0983f8c/mypy-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:b021614cb08d44785b025982163ec3c39c94bff766ead071fa9e82b4ef6f62cd", size = 9972935, upload-time = "2026-05-06T19:23:24.204Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c6/996a1e535e5d0d597c3b1460fc962733091f885f312e749350eb2ac10965/mypy-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ef5f581b61240d1cc629b12f8df6565ed6ffac0d82ed745eef7833222ab50b9", size = 14737259, upload-time = "2026-05-06T19:20:23.081Z" }, + { url = "https://files.pythonhosted.org/packages/94/c5/0f9460e26b77f434bd53f47d1ce32a3cd4580c92a5331fa5dfc059f9421a/mypy-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20e3470a165dbc249bdfbe8d1c5172727ef22688cffc279f8c3aa264ab9d4d9a", size = 13538377, upload-time = "2026-05-06T19:21:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/b2/3e/8ea2f8dd1e5c9c279fb3c28193bdb850adf4d3d8172880abad829eced609/mypy-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224ba142eee8b4d65d4db657cb1fc22abec30b135ded6ab297302ba1f62e505d", size = 13914264, upload-time = "2026-05-06T19:24:12.875Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/78bd3b8520f676acee9dab48ea71473e68f6d5cf14b59fbd800bea50a92b/mypy-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e879ad8a03908ff74d15e8a9b42bf049918e6798d52c011011f1873d0b5877e", size = 14926761, upload-time = "2026-05-06T19:20:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/61/ef/b52fa340522da3d22e669117c3b83155c2660f7cdc035856958fbfffb224/mypy-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5c15bcbd18d6fe927cc55c459597a3517d69cc3123f067be3b020010e115e", size = 15157014, upload-time = "2026-05-06T19:25:49.78Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/dde7614250c6d017936c7aa3bb63b9b52c7cfd298d3f1be9be45f307870b/mypy-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1a068acd7c9fb77e9f8923f1556f2f49d6d7895821121b8d97fa5642b9c52f5", size = 11067049, upload-time = "2026-05-06T19:21:16.116Z" }, + { url = "https://files.pythonhosted.org/packages/27/ec/1d6af4830a94a285442db19caa02f160cc1a255e4f324eec5458e6c2bafb/mypy-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:ef9d96da1ddffbc21f27d3939319b6846d12393baa17c4d2f3e81e040e73ce2c", size = 9967903, upload-time = "2026-05-06T19:22:15.52Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/6fefe954207860aed6eeb91776795e64a257d3ce0360862288984ce121f5/mypy-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c918c64e8ce36557851b0347f84eb12f1965d3a06813c36df253eb0c0afd1d82", size = 14729633, upload-time = "2026-05-06T19:24:53.383Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/d336f5b820af189eb0390cce21de62d264c0a4e64713dfbe81bfc4fc7739/mypy-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:301f1a8ccc7d79b542ee218b28bb49443a83e194eb3d10da63ff1649e5aa5d34", size = 13559524, upload-time = "2026-05-06T19:22:24.906Z" }, + { url = "https://files.pythonhosted.org/packages/af/a6/d7bb54fde1770f0484e5fbdbdce37a41e95ed0a1cd493ec60ead111e356c/mypy-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdf4ef489d44ce350bac3fd699907834e551d4c934e9cc862ef201215ab1558d", size = 13936018, upload-time = "2026-05-06T19:25:02.992Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/5be51316b91e6a6bf6e3a8adb3de500e7e1fb5bf9491743b8cbc81a34a2c/mypy-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cde2d0989f912fc850890f727d0d76495e7a6c5bdd9912a1efdb64952b4398d", size = 14910712, upload-time = "2026-05-06T19:25:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/e2c8c3b373e20ebfb66e6c83a99027fd67df4ec43b08879f74e822d2dc4c/mypy-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdf05693c231a14fe37dbfce192a3a1372c26a833af4a80f550547742952e719", size = 15141499, upload-time = "2026-05-06T19:20:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/12/36/07756f933e00416d912e35878cfcf89a593a3350a885691c0bb85ae0226a/mypy-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:73aee2da33a2237e66cbe84a94780e53599847e86bb3aa7b93e405e8cd9905f2", size = 11240511, upload-time = "2026-05-06T19:21:32.39Z" }, + { url = "https://files.pythonhosted.org/packages/70/05/79ac1f20f2397353f3845f7b8bb5d8006cda7c8ef9092f04f9de3c6135f2/mypy-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:1f6dcd8f39971f41edab2728c877c4ac8b50ad3c387ff2770423b79a05d23910", size = 10149336, upload-time = "2026-05-06T19:22:08.383Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/0db84e0ebbad6e99e566c68e4b465784f2a2294f7719e8db9d509ef23087/mypy-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a04e980b9275c76159da66c6e1723c7798306f9802b31bdaf9358d0c84030ce8", size = 15797362, upload-time = "2026-05-06T19:22:00.835Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a4/14cc0768164dd53bec48aa41a20270b18df9bf72aa5054278bf133608315/mypy-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:33f9cf4825469b2bc73c53ba55f6d9a9b4cdb60f9e6e228745581520f29b8771", size = 14635914, upload-time = "2026-05-06T19:23:43.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/48/d866a3e23b4dc5974c77d9cf65a435bf22de01a84dd4620917950e233960/mypy-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191675c3c7dc2a5c7722a035a6909c277f14046c5e4e02aa5fbf65f8524f08ad", size = 15270866, upload-time = "2026-05-06T19:22:34.756Z" }, + { url = "https://files.pythonhosted.org/packages/71/eb/de9ef94958eb2078a6b908ceb247757dc384d3a238d3bd6ed7d81de5eaf8/mypy-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3d26c4321a3b06fc9f04c741e0733af693f82d823f8e64e47b2e63b7f19fa84", size = 16093131, upload-time = "2026-05-06T19:23:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/ad/07/0ab2c1a9d26e90942612724cbd5788f16b7810c5dd39bfcf79286c6c4524/mypy-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bbcbc4d5917ca6ce12de70e051de7f533e3bf92d548b41a38a2232a6fe356525", size = 16330685, upload-time = "2026-05-06T19:21:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/46f85d1371a5be642dad263828118ae1efd536d91d8bd2000c68acff3920/mypy-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:dbc6ba6d40572ae49268531565793a8f07eac7fc65ad76d482c9b4c8765b6043", size = 12752017, upload-time = "2026-05-06T19:22:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e6/94ca48800cac19eb28a58188a768aaec0d16cac0f373915f073058ab0855/mypy-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:77926029dfcb7e1a3ecb0acb2ddbb24ca36be03f7d623e1759ad5376be8f6c01", size = 10527097, upload-time = "2026-05-06T19:20:58.973Z" }, + { url = "https://files.pythonhosted.org/packages/5c/14/fd0694aa594d6e9f9fd16ce821be2eff295197a273262ef56ddcc1388d68/mypy-2.0.0-py3-none-any.whl", hash = "sha256:8a92b2be3146b4fa1f062af7eb05574cbf3e6eb8e1f14704af1075423144e4e5", size = 2673434, upload-time = "2026-05-06T19:26:32.856Z" }, ] [[package]] @@ -349,29 +392,29 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.2" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] @@ -385,7 +428,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -394,9 +437,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -410,7 +453,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'" }, @@ -421,9 +464,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]] @@ -442,15 +485,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.1" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" }, + { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, ] [[package]] @@ -544,27 +587,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.9" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, - { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, - { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, - { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, - { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, - { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, - { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, - { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -632,7 +675,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.0" +version = "21.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -641,7 +684,7 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, ] From 60b94bae4b7af26bb98c00ea21d4f54b7ae63488 Mon Sep 17 00:00:00 2001 From: ZipFile Date: Tue, 14 Jul 2026 13:01:40 +0000 Subject: [PATCH 69/69] TCP handling logic cleanup --- examples/async_simple_tcp_client.py | 12 +- examples/async_tcp_server.py | 12 +- examples/simple_tcp_client.py | 7 + examples/simple_tcp_server.py | 4 + pythonosc/osc_tcp_server.py | 214 ++++++------------------- pythonosc/parsing/framing.py | 166 +++++++++++++++++++ pythonosc/{ => parsing}/slip.py | 0 pythonosc/tcp_client.py | 165 +++++-------------- pythonosc/test/parsing/test_framing.py | 122 ++++++++++++++ pythonosc/test/test_osc_tcp_server.py | 72 +++++---- pythonosc/test/test_tcp_client.py | 3 +- 11 files changed, 446 insertions(+), 331 deletions(-) create mode 100644 pythonosc/parsing/framing.py rename pythonosc/{ => parsing}/slip.py (100%) create mode 100644 pythonosc/test/parsing/test_framing.py diff --git a/examples/async_simple_tcp_client.py b/examples/async_simple_tcp_client.py index cdadbd3..723861a 100644 --- a/examples/async_simple_tcp_client.py +++ b/examples/async_simple_tcp_client.py @@ -7,8 +7,8 @@ import argparse import asyncio +import logging import random -import sys from pythonosc import tcp_client @@ -47,10 +47,6 @@ async def init_main(): tg.create_task(send_messages(client)) -if sys.version_info >= (3, 7): - asyncio.run(init_main()) -else: - # TODO(python-upgrade): drop this once 3.6 is no longer supported - event_loop = asyncio.get_event_loop() - event_loop.run_until_complete(init_main()) - event_loop.close() +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + asyncio.run(init_main(), debug=True) diff --git a/examples/async_tcp_server.py b/examples/async_tcp_server.py index 532ff79..71ff273 100644 --- a/examples/async_tcp_server.py +++ b/examples/async_tcp_server.py @@ -1,6 +1,6 @@ import argparse import asyncio -import sys +import logging from pythonosc.dispatcher import Dispatcher from pythonosc.osc_tcp_server import AsyncOSCTCPServer @@ -42,10 +42,6 @@ async def init_main(): tg.create_task(loop()) -if sys.version_info >= (3, 7): - asyncio.run(init_main()) -else: - # TODO(python-upgrade): drop this once 3.6 is no longer supported - event_loop = asyncio.get_event_loop() - event_loop.run_until_complete(init_main()) - event_loop.close() +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + asyncio.run(init_main(), debug=True) diff --git a/examples/simple_tcp_client.py b/examples/simple_tcp_client.py index 0834742..b8a173c 100644 --- a/examples/simple_tcp_client.py +++ b/examples/simple_tcp_client.py @@ -5,11 +5,15 @@ """ import argparse +import logging import random +from random import randint +from zlib import crc32 from pythonosc import tcp_client if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") parser.add_argument( @@ -27,6 +31,9 @@ n = random.random() print(f"Sending /filter {n}") client.send_message("/filter", n) + data = bytes(random.randint(0, 255) for i in range(randint(1, 10000))) + print(f"Sending /crc {len(data)} bytes (should be {crc32(data):08X})") + client.send_message("/crc", data) resp = client.get_messages(1) for r in resp: try: diff --git a/examples/simple_tcp_server.py b/examples/simple_tcp_server.py index c1bb0c8..7d549c7 100644 --- a/examples/simple_tcp_server.py +++ b/examples/simple_tcp_server.py @@ -5,7 +5,9 @@ """ import argparse +import logging import math +from zlib import crc32 from pythonosc import osc_tcp_server from pythonosc.dispatcher import Dispatcher @@ -23,6 +25,7 @@ def print_compute_handler(unused_addr, args, volume): if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") parser.add_argument("--port", type=int, default=5005, help="The port to listen on") @@ -36,6 +39,7 @@ def print_compute_handler(unused_addr, args, volume): dispatcher = Dispatcher() dispatcher.map("/filter", print) + dispatcher.map("/crc", lambda _, v: f"{crc32(v):08X}") dispatcher.map("/volume", print_volume_handler, "Volume") dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) diff --git a/pythonosc/osc_tcp_server.py b/pythonosc/osc_tcp_server.py index f3e2778..ac267b8 100644 --- a/pythonosc/osc_tcp_server.py +++ b/pythonosc/osc_tcp_server.py @@ -33,68 +33,37 @@ # mypy: disable-error-code="attr-defined" import asyncio -import logging import os import socket import socketserver -import struct -from typing import List, Tuple +from typing import Tuple -from pythonosc import osc_message_builder, slip +from pythonosc import osc_message_builder from pythonosc.dispatcher import Dispatcher +from pythonosc.parsing.framing import ( + BINARY_LENGTH_FRAMING, + SLIP_FRAMING, + Framing, + FramingParser, +) -LOG = logging.getLogger() MODE_1_0 = "1.0" MODE_1_1 = "1.1" +FRAMING_BY_MODE: dict[str, Framing] = { + MODE_1_0: BINARY_LENGTH_FRAMING, + MODE_1_1: SLIP_FRAMING, +} -class _TCPHandler1_0(socketserver.BaseRequestHandler): - """Handles correct OSC1.0 messages. +def get_framing(mode: str) -> Framing: + try: + return FRAMING_BY_MODE[mode] + except KeyError: + raise ValueError(f"Unsupported OSC mode: {mode}") from None - Whether this will be run on its own thread, the server's or a whole new - process depends on the server you instantiated, look at their documentation. - This method is called after a basic sanity check was done on the datagram, - basically whether this datagram looks like an osc message or bundle, - if not the server won't even bother to call it and so no new - threads/processes will be spawned. - """ - - def handle(self) -> None: - LOG.debug("handle OSC 1.0 protocol") - while True: - lengthbuf = self.recvall(4) - if lengthbuf == b"": - break - (length,) = struct.unpack("!I", lengthbuf) - data = self.recvall(length) - if data == b"": - break - - resp = self.server.dispatcher.call_handlers_for_packet( - data, self.client_address - ) - # resp = _call_handlers_for_packet(data, self.server.dispatcher) - for r in resp: - if not isinstance(r, tuple): - r = [r] - msg = osc_message_builder.build_msg(r[0], r[1:]) - b = struct.pack("!I", len(msg.dgram)) - self.request.sendall(b + msg.dgram) - - def recvall(self, count: int) -> bytes: - buf = b"" - while count > 0: - newbuf = self.request.recv(count) - if not newbuf: - return b"" - buf += newbuf - count -= len(newbuf) - return buf - - -class _TCPHandler1_1(socketserver.BaseRequestHandler): - """Handles correct OSC1.1 messages. +class TCPHandler(socketserver.BaseRequestHandler): + """Handles correct OSC messages. Whether this will be run on its own thread, the server's or a whole new process depends on the server you instantiated, look at their documentation. @@ -106,14 +75,12 @@ class _TCPHandler1_1(socketserver.BaseRequestHandler): """ def handle(self) -> None: - LOG.debug("handle OSC 1.1 protocol") - while True: - packets = self.recvall() - if not packets: - break - - for p in packets: - # resp = _call_handlers_for_packet(p, self.server.dispatcher) + assert isinstance(self.server, OSCTCPServer) + + parser = FramingParser(self.server.framing) + + while chunk := self.request.recv(16384): + for p in parser.feed(chunk): resp = self.server.dispatcher.call_handlers_for_packet( p, self.client_address ) @@ -121,27 +88,15 @@ def handle(self) -> None: if not isinstance(r, tuple): r = [r] msg = osc_message_builder.build_msg(r[0], r[1:]) - self.request.sendall(slip.encode(msg.dgram)) - - def recvall(self) -> List[bytes]: - buf = self.request.recv(4096) - if not buf: - return [] - # If the last byte is not an END marker there could be more data coming - while buf[-1] != 192: - newbuf = self.request.recv(4096) - if not newbuf: - # Maybe should raise an exception here? - break - buf += newbuf - - packets = [slip.decode(p) for p in buf.split(slip.END_END)] - return packets + self.request.sendall(self.server.framing.encode(msg.dgram)) class OSCTCPServer(socketserver.TCPServer): """Superclass for different flavors of OSCTCPServer""" + dispatcher: Dispatcher + framing: Framing + def __init__( self, server_address: Tuple[str | bytes | bytearray, int], @@ -150,9 +105,8 @@ def __init__( family: socket.AddressFamily | None = None, ): self.request_queue_size = 300 - self.mode = mode - if mode not in [MODE_1_0, MODE_1_1]: - raise ValueError("OSC Mode must be '1.0' or '1.1'") + self.dispatcher = dispatcher + self.framing = get_framing(mode) if family is not None: self.address_family = family @@ -171,16 +125,7 @@ def __init__( # Fallback to default if resolution fails pass - if self.mode == MODE_1_0: - super().__init__(server_address, _TCPHandler1_0) - else: - super().__init__(server_address, _TCPHandler1_1) - self._dispatcher = dispatcher - - @property - def dispatcher(self): - """Dispatcher accessor for handlers to dispatch osc messages.""" - return self._dispatcher + super().__init__(server_address, TCPHandler) class BlockingOSCTCPServer(OSCTCPServer): @@ -230,8 +175,8 @@ def __init__( """ self._port = port self._server_address = server_address - self._dispatcher = dispatcher - self._mode = mode + self.dispatcher = dispatcher + self.framing = get_framing(mode) async def __aenter__(self): return self @@ -245,9 +190,6 @@ async def start(self) -> None: self.handle, self._server_address, self._port ) - addrs = ", ".join(str(sock.getsockname()) for sock in self._server.sockets) - LOG.debug(f"Serving on {addrs}") - async with self._server: await self._server.serve_forever() @@ -255,86 +197,28 @@ async def stop(self) -> None: self._server.close() await self._server.wait_closed() - @property - def dispatcher(self): - return self._dispatcher - async def handle( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: + try: + await self._handle(reader, writer) + finally: + if writer.can_write_eof(): + writer.write_eof() + writer.close() + await writer.wait_closed() + + async def _handle( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + parser = FramingParser(self.framing) client_address = ("", 0) sock = writer.transport.get_extra_info("socket") if sock is not None: client_address = sock.getpeername() - if self._mode == MODE_1_1: - await self.handle_1_1(reader, writer, client_address) - else: - await self.handle1_0(reader, writer, client_address) - writer.write_eof() - LOG.debug("Close the connection") - writer.close() - await writer.wait_closed() - - async def handle1_0( - self, - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - client_address: Tuple[str, int], - ) -> None: - LOG.debug("Incoming socket open 1.0") - while True: - try: - buf = await reader.read(4) - except Exception as e: - LOG.exception("Read error", e) - return - if buf == b"": - break - (length,) = struct.unpack("!I", buf) - buf = b"" - while length > 0: - newbuf = await reader.read(length) - if not newbuf: - break - buf += newbuf - length -= len(newbuf) - - result = await self.dispatcher.async_call_handlers_for_packet( - buf, client_address - ) - for r in result: - if not isinstance(r, tuple): - r = [r] - msg = osc_message_builder.build_msg(r[0], r[1:]) - b = struct.pack("!I", len(msg.dgram)) - writer.write(b + msg.dgram) - await writer.drain() - - async def handle_1_1( - self, - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - client_address: Tuple[str, int], - ) -> None: - LOG.debug("Incoming socket open 1.1") - while True: - try: - buf = await reader.read(4096) - except Exception as e: - LOG.exception("Read error", e) - return - if buf == b"": - break - while len(buf) > 0 and buf[-1] != 192: - newbuf = await reader.read(4096) - if not newbuf: - # Maybe should raise an exception here? - break - buf += newbuf - - packets = [slip.decode(p) for p in buf.split(slip.END_END)] - for p in packets: + while chunk := await reader.read(16384): + for p in parser.feed(chunk): result = await self.dispatcher.async_call_handlers_for_packet( p, client_address ) @@ -342,5 +226,5 @@ async def handle_1_1( if not isinstance(r, tuple): r = [r] msg = osc_message_builder.build_msg(r[0], r[1:]) - writer.write(slip.encode(msg.dgram)) + writer.write(self.framing.encode(msg.dgram)) await writer.drain() diff --git a/pythonosc/parsing/framing.py b/pythonosc/parsing/framing.py new file mode 100644 index 0000000..6e19dee --- /dev/null +++ b/pythonosc/parsing/framing.py @@ -0,0 +1,166 @@ +"""OSC message codecs for different framing strategies.""" + +from abc import ABCMeta, abstractmethod +from struct import pack, unpack_from + +from pythonosc.parsing.slip import ( + END, + END_END, + decode as slip_decode, + encode as slip_encode, +) + + +class Framing(metaclass=ABCMeta): + """Base class for OSC stream framing strategies.""" + + @abstractmethod + def encode(self, dgram: bytes) -> bytes: + """Encode a single OSC datagram for transmission on a byte stream. + + Args: + dgram: Raw OSC datagram bytes. + + Returns: + The framed byte sequence to write to the stream. + """ + + @abstractmethod + def decode_stream( + self, buf: bytes | bytearray, start: int = 0 + ) -> tuple[list[bytes], int]: + """Decode complete OSC datagrams from a byte buffer. + + Args: + buf: Bytes received from a stream. + start: The index in ``buf`` to start decoding from. + + Returns: + A tuple ``(messages, bytes consumed)``. + """ + + +class SLIPFraming(Framing): + """SLIP (RFC 1055) framing for OSC 1.1. + + See the "OSC Delivery Specification 1.1" section from the 2009 NIME paper for details. + """ + + def encode(self, dgram: bytes) -> bytes: + return slip_encode(dgram) + + def decode_stream( + self, buf: bytes | bytearray, start: int = 0 + ) -> tuple[list[bytes], int]: + pos = start + messages = [] + + while (end := buf.find(END_END, pos)) >= 0: + if end > pos: + messages.append(slip_decode(bytes(buf[pos:end]))) + + pos = end + len(END) + + if buf.endswith(END): + messages.append(slip_decode(bytes(buf[pos:end]))) + pos = len(buf) + + return messages, pos - start + + +class BinaryLengthFraming(Framing): + """Length-prefixed framing for OSC 1.0. + + See the "OSC Packets" section from OSC 1.0 Specification for details. + """ + + def encode(self, dgram: bytes) -> bytes: + if dgram: + return pack("!I", len(dgram)) + dgram + return b"" + + def decode_stream( + self, buf: bytes | bytearray, start: int = 0 + ) -> tuple[list[bytes], int]: + messages = [] + pos = start + + while (data_pos := pos + 4) < len(buf): + (length,) = unpack_from("!I", buf, pos) + end = data_pos + length + + if end > len(buf): + break + + messages.append(bytes(buf[data_pos:end])) + + pos = end + + return messages, pos - start + + +class FramingParser: + """Stateful stream decoder for (framed) OSC byte stream. + + Not safe for concurrent use. + + Args: + framing: Strategy used to decode buffered stream data. + max_buffer_size: Maximum size of the internal buffer. + """ + + __slots__ = ("buf", "start", "framing", "max_buffer_size") + + def __init__(self, framing: Framing, max_buffer_size: int = 1048576) -> None: + self.buf = bytearray() + self.start = 0 + self.framing = framing + self.max_buffer_size = max_buffer_size + + def feed(self, chunk: bytes | bytearray | memoryview) -> list[bytes]: + """Append stream data to the internal buffer and return any complete OSC datagrams. + + Args: + chunk: Newly received bytes from the stream. + + Returns: + A list of complete, decoded OSC datagrams. + """ + + new_buf_len = len(self.buf) + len(chunk) + + if (pending := new_buf_len - self.start) > self.max_buffer_size: + raise ValueError(f"Buffer is too large: {pending} > {self.max_buffer_size}") + + if new_buf_len > self.max_buffer_size: + del self.buf[: self.start] + self.start = 0 + + self.buf.extend(chunk) + + messages, consumed = self.framing.decode_stream(self.buf, self.start) + available = len(self.buf) - self.start + + assert 0 <= consumed <= available, ( + f"misbehaving framing decoder: {consumed=} {available=}" + ) + + self.start += consumed + + return messages + + @property + def remaining(self) -> bytes: + """Return any unparsed bytes.""" + + return bytes(self.buf[self.start :]) + + def reset(self) -> None: + """Clear the internal buffer (discard unparsed data).""" + + del self.buf[:] + self.start = 0 + + +SLIP_FRAMING: Framing = SLIPFraming() +BINARY_LENGTH_FRAMING: Framing = BinaryLengthFraming() diff --git a/pythonosc/slip.py b/pythonosc/parsing/slip.py similarity index 100% rename from pythonosc/slip.py rename to pythonosc/parsing/slip.py diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index 4d4d858..17a2e4f 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -2,19 +2,19 @@ import asyncio import socket -import struct -from typing import AsyncGenerator, Generator, Iterable, List, Union +from contextlib import suppress +from typing import Any, AsyncGenerator, Awaitable, Generator, Iterable, List, Union -from pythonosc import slip from pythonosc.dispatcher import Dispatcher from pythonosc.osc_bundle import OscBundle from pythonosc.osc_message import OscMessage from pythonosc.osc_message_builder import ArgValue, build_msg -from pythonosc.osc_tcp_server import MODE_1_1 +from pythonosc.osc_tcp_server import MODE_1_1, get_framing +from pythonosc.parsing.framing import FramingParser -class TCPClient(object): - """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" +class TCPClient: + """OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" def __init__( self, @@ -32,11 +32,13 @@ def __init__( family: address family parameter (passed to socket.getaddrinfo) timeout: Default timeout in seconds for socket operations """ + self.address = address self.port = port self.family = family - self.mode = mode self._timeout = timeout + self._framing = get_framing(mode) + self._parser = FramingParser(self._framing) self.socket = socket.socket(self.family, socket.SOCK_STREAM) self.socket.settimeout(timeout) self.socket.connect((address, port)) @@ -53,61 +55,27 @@ def send(self, content: Union[OscMessage, OscBundle]) -> None: Args: content: Message or bundle to be sent """ - if self.mode == MODE_1_1: - self.socket.sendall(slip.encode(content.dgram)) - else: - b = struct.pack("!I", len(content.dgram)) - self.socket.sendall(b + content.dgram) + self.socket.sendall(self._framing.encode(content.dgram)) def receive(self, timeout: float | None = None) -> List[bytes]: + messages = [] effective_timeout = timeout if timeout is not None else self._timeout self.socket.settimeout(effective_timeout) - if self.mode == MODE_1_1: - try: - buf = self.socket.recv(4096) - except (TimeoutError, socket.timeout): - return [] - if not buf: - return [] - # If the last byte is not an END marker there could be more data coming - while buf[-1] != 192: - try: - newbuf = self.socket.recv(4096) - except (TimeoutError, socket.timeout): - break - if not newbuf: - # Maybe should raise an exception here? - break - buf += newbuf - return [slip.decode(p) for p in buf.split(slip.END_END)] - else: - buf = b"" - try: - lengthbuf = self.socket.recv(4) - except (TimeoutError, socket.timeout): - return [] - (length,) = struct.unpack("!I", lengthbuf) - while length > 0: - try: - newbuf = self.socket.recv(length) - except (TimeoutError, socket.timeout): - return [] - if not newbuf: - return [] - buf += newbuf - length -= len(newbuf) - return [buf] + + with suppress(TimeoutError): + while chunk := self.socket.recv(16384): + messages.extend(self._parser.feed(chunk)) + + return messages def close(self): + self._parser.reset() self.socket.close() class SimpleTCPClient(TCPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - def send_message( self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" ) -> None: @@ -131,7 +99,9 @@ def get_messages(self, timeout: float | None = None) -> Generator: class TCPDispatchClient(SimpleTCPClient): """OSC TCP Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" - dispatcher = Dispatcher() + def __init__(self, *args: Any, dispatcher: Dispatcher | None = None, **kwargs: Any): + self.dispatcher = dispatcher or Dispatcher() + super().__init__(*args, **kwargs) def handle_messages(self, timeout_sec: float | None = None) -> None: """Wait :int:`timeout` seconds for a message from the server and process each message with the registered @@ -168,16 +138,10 @@ def __init__( """ self.address: str = address self.port: int = port - self.mode: str = mode self.family: socket.AddressFamily = family self._timeout = timeout - - def __await__(self): - async def closure(): - await self.__open__() - return self - - return closure().__await__() + self._framing = get_framing(mode) + self._parser = FramingParser(self._framing) async def __aenter__(self): await self.__open__() @@ -191,83 +155,38 @@ async def __open__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() - async def send(self, content: Union[OscMessage, OscBundle]) -> None: + def send(self, content: Union[OscMessage, OscBundle]) -> Awaitable[None]: """Sends an :class:`OscMessage` or :class:`OscBundle` via TCP Args: content: Message or bundle to be sent """ - if self.mode == MODE_1_1: - self.writer.write(slip.encode(content.dgram)) - else: - b = struct.pack("!I", len(content.dgram)) - self.writer.write(b + content.dgram) - await self.writer.drain() + self.writer.write(self._framing.encode(content.dgram)) + return self.writer.drain() + + def _read_with_timeout(self, timeout: float | None) -> Awaitable[bytes]: + return asyncio.wait_for(self.reader.read(16384), timeout) async def receive(self, timeout: float | None = None) -> List[bytes]: + messages = [] effective_timeout = timeout if timeout is not None else self._timeout - if self.mode == MODE_1_1: - try: - buf = await asyncio.wait_for(self.reader.read(4096), effective_timeout) - except (TimeoutError, asyncio.TimeoutError): - return [] - if not buf: - return [] - # If the last byte is not an END marker there could be more data coming - while buf[-1] != 192: - try: - newbuf = await asyncio.wait_for( - self.reader.read(4096), effective_timeout - ) - except (TimeoutError, asyncio.TimeoutError): - break - if not newbuf: - # Maybe should raise an exception here? - break - buf += newbuf - return [slip.decode(p) for p in buf.split(slip.END_END)] - else: - buf = b"" - try: - lengthbuf = await asyncio.wait_for( - self.reader.read(4), effective_timeout - ) - except (TimeoutError, asyncio.TimeoutError): - return [] - - (length,) = struct.unpack("!I", lengthbuf) - while length > 0: - try: - newbuf = await asyncio.wait_for( - self.reader.read(length), effective_timeout - ) - except (TimeoutError, asyncio.TimeoutError): - return [] - if not newbuf: - return [] - buf += newbuf - length -= len(newbuf) - return [buf] - - async def close(self): + + with suppress(TimeoutError): + while chunk := await self._read_with_timeout(effective_timeout): + messages.extend(self._parser.feed(chunk)) + + return messages + + def close(self) -> Awaitable[None]: + self._parser.reset() self.writer.write_eof() self.writer.close() - await self.writer.wait_closed() + return self.writer.wait_closed() class AsyncSimpleTCPClient(AsyncTCPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" - def __init__( - self, - address: str, - port: int, - family: socket.AddressFamily = socket.AF_INET, - mode: str = MODE_1_1, - timeout: float | None = 30.0, - ): - super().__init__(address, port, family, mode, timeout) - async def send_message( self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" ) -> None: @@ -291,7 +210,9 @@ async def get_messages(self, timeout: float | None = None) -> AsyncGenerator: class AsyncDispatchTCPClient(AsyncTCPClient): """OSC Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" - dispatcher = Dispatcher() + def __init__(self, *args: Any, dispatcher: Dispatcher | None = None, **kwargs: Any): + self.dispatcher = dispatcher or Dispatcher() + super().__init__(*args, **kwargs) async def handle_messages(self, timeout: float | None = None) -> None: """Wait :int:`timeout` seconds for a message from the server and process each message with the registered diff --git a/pythonosc/test/parsing/test_framing.py b/pythonosc/test/parsing/test_framing.py new file mode 100644 index 0000000..72361de --- /dev/null +++ b/pythonosc/test/parsing/test_framing.py @@ -0,0 +1,122 @@ +from pytest import fixture, mark, raises + +from pythonosc.parsing.framing import ( + BinaryLengthFraming, + Framing, + FramingParser, + SLIPFraming, +) + + +class SquareFraming(Framing): + """Framing strategy for ease of testing, wraps everything in [] brackets.""" + + def encode(self, data: bytes) -> bytes: + if data: + return b"[" + data + b"]" + return b"" + + def decode_stream( + self, chunk: bytes | bytearray, start: int = 0 + ) -> tuple[list[bytes], int]: + out = [] + pos = start + + while (a := chunk.find(b"[", pos)) >= 0: + a += 1 + + if (b := chunk.find(b"]", a)) >= 0: + out.append(bytes(chunk[a:b])) + pos = b + 1 + else: + break + + return out, pos - start + + +class CommonFramingTest: + def test_encode_decode(self, framing: Framing) -> None: + messages = [b"test1", b"", b"test2", b"test3"] + expected = [m for m in messages if m] + data = b"".join(framing.encode(message) for message in messages) + + assert framing.decode_stream(data) == (expected, len(data)) + + @mark.parametrize("n,m", [(1, 1), (4, 1), (64, 0)]) + def test_incomplete(self, framing: Framing, n: int, m: int) -> None: + message_a = framing.encode(b"test") + message_b = framing.encode(b"cut here") + data = (message_a + message_b)[:-n] + messages, consumed = framing.decode_stream(data) + + assert messages == messages[:m] + assert data[consumed:] == data[len(message_a) :] + + def test_decode_with_start(self, framing: Framing) -> None: + garbage = b"garbage" + data = garbage + framing.encode(b"test") + + assert framing.decode_stream(data, len(garbage)) == ( + [b"test"], + len(data) - len(garbage), + ) + + +class TestSLIPFraming(CommonFramingTest): + @fixture + def framing(self) -> Framing: + return SLIPFraming() + + def test_encode(self, framing: Framing) -> None: + assert framing.encode(b"te\xc0st") == b"\xc0te\xdb\xdcst\xc0" + + +class TestBinaryLengthFraming(CommonFramingTest): + @fixture + def framing(self) -> Framing: + return BinaryLengthFraming() + + def test_encode(self, framing: Framing) -> None: + assert framing.encode(b"test") == b"\x00\x00\x00\x04test" + + +class TestSquareFraming(CommonFramingTest): + @fixture + def framing(self) -> Framing: + return SquareFraming() + + def test_encode(self, framing: Framing) -> None: + assert framing.encode(b"test") == b"[test]" + + +class TestFramingParser: + @fixture + def parser(self) -> FramingParser: + return FramingParser(SquareFraming(), max_buffer_size=16) + + def test_cold(self, parser: FramingParser) -> None: + assert parser.feed(b"[test]") == [b"test"] + + def test_hot(self, parser: FramingParser) -> None: + assert parser.feed(b"[test") == [] + assert parser.feed(b"123][xxx]") == [b"test123", b"xxx"] + + def test_large_buffer(self, parser: FramingParser) -> None: + parser.feed(b"[test]") + + with raises(ValueError, match="Buffer is too large: 18 > 16"): + parser.feed(b"[test]" * 3) + + def test_compaction(self, parser: FramingParser) -> None: + for _ in range(4): + assert parser.feed(b"[tests]") == [b"tests"] + + def test_reset(self, parser: FramingParser) -> None: + parser.feed(b"[test]") + parser.feed(b"[xxx") + + assert parser.remaining == b"[xxx" + + parser.reset() + + assert parser.remaining == b"" diff --git a/pythonosc/test/test_osc_tcp_server.py b/pythonosc/test/test_osc_tcp_server.py index 0849803..ef5ecaf 100644 --- a/pythonosc/test/test_osc_tcp_server.py +++ b/pythonosc/test/test_osc_tcp_server.py @@ -2,8 +2,11 @@ import unittest import unittest.mock as mock +from pytest import mark, raises + from pythonosc import dispatcher, osc_tcp_server -from pythonosc.slip import END +from pythonosc.parsing.framing import BINARY_LENGTH_FRAMING, SLIP_FRAMING, Framing +from pythonosc.parsing.slip import END _SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00,i\x00\x00\x00\x00\x00\x04" @@ -21,15 +24,31 @@ _SIMPLE_MSG_NO_PARAMS_1_1 = END + _SIMPLE_MSG_NO_PARAMS + END +class TestGetFraming: + @mark.parametrize( + ["mode", "framing"], + [ + (osc_tcp_server.MODE_1_0, BINARY_LENGTH_FRAMING), + (osc_tcp_server.MODE_1_1, SLIP_FRAMING), + ], + ) + def test_ok(self, mode: str, framing: Framing) -> None: + assert osc_tcp_server.get_framing(mode) is framing + + def test_unknown(self) -> None: + with raises(ValueError, match=r"Unsupported OSC mode: test"): + osc_tcp_server.get_framing("test") + + class TestTCP_1_1_Handler(unittest.TestCase): def setUp(self): super().setUp() self.dispatcher = dispatcher.Dispatcher() # We do not want to create real UDP connections during unit tests. - self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) - # Need to attach property mocks to types, not objects... weird. - type(self.server).dispatcher = unittest.mock.PropertyMock( - return_value=self.dispatcher + self.server = unittest.mock.Mock( + spec=osc_tcp_server.BlockingOSCTCPServer, + dispatcher=self.dispatcher, + framing=SLIP_FRAMING, ) self.client_address = ("127.0.0.1", 8080) self.mock_meth = unittest.mock.MagicMock() @@ -44,7 +63,7 @@ def test_no_match(self): _SIMPLE_PARAM_INT_MSG_1_1, b"", ] - osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.assertFalse(self.mock_meth.called) def test_match_with_args(self): @@ -52,7 +71,7 @@ def test_match_with_args(self): mock_sock = mock.Mock() mock_sock.recv = mock.Mock() mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, b""] - osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) def test_match_int9(self): @@ -60,7 +79,7 @@ def test_match_int9(self): mock_sock = mock.Mock() mock_sock.recv = mock.Mock() mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_9_1_1, b""] - osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.assertTrue(self.mock_meth.called) self.mock_meth.assert_called_with("/debug", 9) @@ -69,7 +88,7 @@ def test_match_without_args(self): mock_sock = mock.Mock() mock_sock.recv = mock.Mock() mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] - osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_match_default_handler(self): @@ -77,7 +96,7 @@ def test_match_default_handler(self): mock_sock = mock.Mock() mock_sock.recv = mock.Mock() mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] - osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_response_no_args(self): @@ -90,7 +109,7 @@ def respond(*args, **kwargs): mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) mock_sock.sendall.assert_called_with(b"\xc0/SYNC\00\00\00,\00\00\00\xc0") def test_response_with_args(self): @@ -108,7 +127,7 @@ def respond(*args, **kwargs): mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) mock_sock.sendall.assert_called_with( b"\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0" ) @@ -119,10 +138,10 @@ def setUp(self): super().setUp() self.dispatcher = dispatcher.Dispatcher() # We do not want to create real UDP connections during unit tests. - self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) - # Need to attach property mocks to types, not objects... weird. - type(self.server).dispatcher = unittest.mock.PropertyMock( - return_value=self.dispatcher + self.server = unittest.mock.Mock( + spec=osc_tcp_server.BlockingOSCTCPServer, + dispatcher=self.dispatcher, + framing=BINARY_LENGTH_FRAMING, ) self.client_address = ("127.0.0.1", 8080) self.mock_meth = unittest.mock.MagicMock() @@ -139,7 +158,7 @@ def test_no_match(self): _SIMPLE_PARAM_INT_MSG, b"", ] - osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.assertFalse(self.mock_meth.called) def test_match_with_args(self): @@ -151,7 +170,7 @@ def test_match_with_args(self): _SIMPLE_PARAM_INT_MSG, b"", ] - osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) def test_match_int9(self): @@ -159,7 +178,7 @@ def test_match_int9(self): mock_sock = mock.Mock() mock_sock.recv = mock.Mock() mock_sock.recv.side_effect = [LEN_SIMPLE_PARAM_INT_9, _SIMPLE_PARAM_INT_9, b""] - osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.assertTrue(self.mock_meth.called) self.mock_meth.assert_called_with("/debug", 9) @@ -172,7 +191,7 @@ def test_match_without_args(self): _SIMPLE_MSG_NO_PARAMS, b"", ] - osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_match_default_handler(self): @@ -184,7 +203,7 @@ def test_match_default_handler(self): _SIMPLE_MSG_NO_PARAMS, b"", ] - osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) self.mock_meth.assert_called_with("/SYNC") def test_response_no_args(self): @@ -201,7 +220,7 @@ def respond(*args, **kwargs): ] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) mock_sock.sendall.assert_called_with( b"\x00\x00\x00\x0c/SYNC\00\00\00,\00\00\00" ) @@ -225,7 +244,7 @@ def respond(*args, **kwargs): ] mock_sock.sendall = mock.Mock() mock_sock.sendall.return_value = None - osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) + osc_tcp_server.TCPHandler(mock_sock, self.client_address, self.server) mock_sock.sendall.assert_called_with( b"\x00\x00\x00\x1c/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00" ) @@ -236,10 +255,9 @@ def setUp(self): super().setUp() self.dispatcher = dispatcher.Dispatcher() # We do not want to create real UDP connections during unit tests. - self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) - # Need to attach property mocks to types, not objects... weird. - type(self.server).dispatcher = unittest.mock.PropertyMock( - return_value=self.dispatcher + self.server = unittest.mock.Mock( + spec=osc_tcp_server.BlockingOSCTCPServer, + dispatcher=self.dispatcher, ) self.client_address = ("127.0.0.1", 8080) self.mock_writer = mock.Mock() diff --git a/pythonosc/test/test_tcp_client.py b/pythonosc/test/test_tcp_client.py index a0089f4..21e9f01 100644 --- a/pythonosc/test/test_tcp_client.py +++ b/pythonosc/test/test_tcp_client.py @@ -2,7 +2,8 @@ import unittest from unittest import mock -from pythonosc import osc_message_builder, slip, tcp_client +from pythonosc import osc_message_builder, tcp_client +from pythonosc.parsing import slip class TestTcpClient(unittest.TestCase):