diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8766d1e..5bb6b56 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -97,5 +97,4 @@ jobs: - name: Build and deploy documentation run: | - uv run --group doc python docs/_generate_requests_docstrings.py uv run --group doc mike deploy --push --update-aliases ${GIT_TAG} latest diff --git a/.github/workflows/test-docs.yaml b/.github/workflows/test-docs.yaml index 7c96bdb..51f80be 100644 --- a/.github/workflows/test-docs.yaml +++ b/.github/workflows/test-docs.yaml @@ -17,5 +17,4 @@ jobs: - name: Build docs run: | - uv run --group doc python docs/_generate_requests_docstrings.py uv run --group doc mkdocs build diff --git a/docs/_generate_requests_docstrings.py b/docs/_generate_requests_docstrings.py deleted file mode 100644 index 66a97cc..0000000 --- a/docs/_generate_requests_docstrings.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Iterate through smpclient/requests and add inherited docstrings. - -This file iterates through all of the python files in the smpclient/requests -directory and changes them. It will import the classes and check if they have -a docstring or not. If they do not have a docstring, it will get the docstring -from the parent class, add it to the class, and rewrite the file. - -This is solely for generating documentation with mkdocs. - -It is wrangled LLM code and should be replaced ASAP. -""" - -import ast -import importlib.util -import inspect -import os -from typing import Any - -from pydantic import BaseModel - - -class ClassInfo: - def __init__(self, name: str, lineno: int, col_offset: int, original_text: str): - self.name = name - self.lineno = lineno - self.col_offset = col_offset - self.original_text = original_text - self.docstring: str | None = None - - def add_docstring(self, docstring: str) -> None: - """Add a docstring to the class.""" - indent = ' ' * (self.col_offset + 4) - formatted_docstring = format_docstring(docstring, indent) - self.docstring = formatted_docstring - - def get_updated_text(self) -> str: - """Get the updated class text with the new docstring.""" - if self.docstring: - lines = self.original_text.split('\n') - lines.insert(1, self.docstring) - return '\n'.join(lines) - return self.original_text - - -def format_docstring(docstring: str, indent: str) -> str: - """Format the docstring with the correct indentation.""" - lines = docstring.split('\n') - indented_lines = [f'{indent}"""{lines[0]}\n'] - indented_lines += [f'{line}' for line in lines[1:]] - indented_lines.append(f'{indent}"""') - return '\n'.join(indented_lines) - - -def get_docstring_from_parent(cls: type) -> str | None: - """Get the docstring from the parent class.""" - for base in cls.__bases__: - if base.__doc__: - return base.__doc__ - return None - - -def get_field_docstring(cls: type[BaseModel], field_name: str) -> str: - """Get the docstring of a field from the class.""" - for name, obj in inspect.getmembers(cls): - if name == field_name: - return obj.__doc__ or "No docstring provided." - return "No docstring found." - - -def format_type(annotation: type[Any] | None) -> str: - """Format the type to show module and class name.""" - if annotation is None: - raise ValueError("Annotation cannot be None") - if hasattr(annotation, '__name__'): # Handles regular types like `int`, `str`, etc. - # get the annotations like list[str] for example - if hasattr(annotation, '__args__'): - return f"{annotation.__name__}[{format_type(annotation.__args__[0])}]" - return f"{annotation.__name__}" - elif hasattr(annotation, '__origin__'): # Handles generic types like list[str], Optional[int] - return f"{annotation.__origin__.__module__}.{annotation.__origin__.__name__}" - return str(annotation) # Fallback for other types - - -def get_pydantic_fields(cls: type[BaseModel]) -> str: - """Get the fields of a Pydantic model and format them as Google-style Args.""" - if not issubclass(cls, BaseModel): - return "" - - fields = cls.model_fields - args = "\n Args:\n" - for field_name, field_info in fields.items(): - if field_name in ("header, version, sequence, smp_data"): - continue - field_type = format_type(field_info.annotation) - - # split the field_info.description by newlines and join them with a newline - # and 12 spaces, removing blank lines - description = ( - "\n ".join( - filter(lambda x: x.strip() != "", field_info.description.split("\n")) - ) - if field_info.description - else "" - ) - - args += f" {field_name} ({field_type}): {description}\n" - if args.endswith("Args:\n"): - return "" - return args - - -def parse_file(file_path: str) -> list[ClassInfo]: - """Parse the file and extract class definitions.""" - with open(file_path) as file: - lines = file.readlines() - tree = ast.parse(''.join(lines)) - - classes = [] - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - class_name = node.name - lineno = node.lineno - 1 - col_offset = node.col_offset - class_body = lines[lineno : lineno + len(node.body) + 1] - original_text = ''.join(class_body) - classes.append(ClassInfo(class_name, lineno, col_offset, original_text)) - return classes - - -def update_class_docstrings(file_path: str) -> None: - """Update class docstrings in a given file.""" - classes = parse_file(file_path) - module_name = file_path.replace('/', '.').replace('.py', '') - spec = importlib.util.spec_from_file_location(module_name, file_path) - if spec is None: - raise ValueError(f"Could not find spec for {module_name}") - module = importlib.util.module_from_spec(spec) - if spec.loader is None: - raise ValueError(f"Could not find loader for {module_name}") - spec.loader.exec_module(module) - - for class_info in classes: - cls = getattr(module, class_info.name) - if not cls.__doc__: - parent_docstring = get_docstring_from_parent(cls) - if parent_docstring: - args_section = get_pydantic_fields(cls) - full_docstring = parent_docstring + args_section - class_info.add_docstring(full_docstring) - - with open(file_path, encoding="utf-8") as file: - lines = file.readlines() - - updated_lines = [] - class_index = 0 - for i, line in enumerate(lines): - if class_index < len(classes) and i == classes[class_index].lineno: - updated_lines.append(classes[class_index].get_updated_text()) - class_index += 1 - else: - updated_lines.append(line) - - with open(file_path, 'w', encoding="utf-8") as file: - file.writelines(updated_lines) - - -def main() -> None: - directory = 'smpclient/requests' - for root, _, files in os.walk(directory): - for file in files: - if file.endswith('.py'): - file_path = os.path.join(root, file) - update_class_docstrings(file_path) - - -if __name__ == '__main__': - main() diff --git a/docs/file_management.md b/docs/file_management.md deleted file mode 100644 index db27a45..0000000 --- a/docs/file_management.md +++ /dev/null @@ -1,6 +0,0 @@ -# File Management - -Refer to the [smp File Management documentation](https://jphutchins.github.io/smp/latest/file_management/) -for a complete description of each Request and Response. - -::: smpclient.requests.file_management \ No newline at end of file diff --git a/docs/image_management.md b/docs/image_management.md deleted file mode 100644 index 83a8fbc..0000000 --- a/docs/image_management.md +++ /dev/null @@ -1,6 +0,0 @@ -# Image Management - -Refer to the [smp Image Management documentation](https://jphutchins.github.io/smp/latest/image_management/) -for a complete description of each Request and Response. - -::: smpclient.requests.image_management \ No newline at end of file diff --git a/docs/os_management.md b/docs/os_management.md deleted file mode 100644 index 6f91dba..0000000 --- a/docs/os_management.md +++ /dev/null @@ -1,6 +0,0 @@ -# OS Management - -Refer to the [smp OS Management documentation](https://jphutchins.github.io/smp/latest/os_management/) -for a complete description of each Request and Response. - -::: smpclient.requests.os_management \ No newline at end of file diff --git a/docs/requests.md b/docs/requests.md index 4f204e4..997faa3 100644 --- a/docs/requests.md +++ b/docs/requests.md @@ -1,6 +1,45 @@ -# Requests Helpers +# Requests and Responses -Refer to the [smp documentation](https://jphutchins.github.io/smp/latest/) for a -complete description of each SMP Request and Response. +Every SMP Request and Response is defined by +[smp](https://jphutchins.github.io/smp/latest/), and each Request carries the +`Response`, `ErrorV1`, and `ErrorV2` it may provoke. `SMPClient.request()` therefore +returns a union that narrows exhaustively: -::: smpclient.generics +```python +response = await client.request(EchoWriteRequest(d="Hello, World!")) + +if success(response): + print(response.r) +elif error_v1(response): + print(response.rc) +elif error_v2(response): + print(response.err.rc) +else: + assert_never(response) +``` + +## Groups + +Construct requests directly from `smp`: + +| Group | Reference | +| --- | --- | +| Enumeration Management | [smp.enumeration_management](https://jphutchins.github.io/smp/latest/enumeration_management/) | +| File Management | [smp.file_management](https://jphutchins.github.io/smp/latest/file_management/) | +| Image Management | [smp.image_management](https://jphutchins.github.io/smp/latest/image_management/) | +| OS Management | [smp.os_management](https://jphutchins.github.io/smp/latest/os_management/) | +| Settings Management | [smp.settings_management](https://jphutchins.github.io/smp/latest/settings_management/) | +| Shell Management | [smp.shell_management](https://jphutchins.github.io/smp/latest/shell_management/) | +| Statistics Management | [smp.statistics_management](https://jphutchins.github.io/smp/latest/statistics_management/) | +| Zephyr Management | [smp.zephyr_management](https://jphutchins.github.io/smp/latest/zephyr_management/) | +| Intercreate (user) | [smp.user.intercreate](https://jphutchins.github.io/smp/latest/user/intercreate/) | + +## Narrowing helpers + +::: smpclient.success + +::: smpclient.error + +::: smpclient.error_v1 + +::: smpclient.error_v2 diff --git a/docs/settings_management.md b/docs/settings_management.md deleted file mode 100644 index e360497..0000000 --- a/docs/settings_management.md +++ /dev/null @@ -1,6 +0,0 @@ -# Settings Management - -Refer to the [smp Settings Management documentation](https://jphutchins.github.io/smp/latest/settings_management/) -for a complete description of each Request and Response. - -::: smpclient.requests.settings_management \ No newline at end of file diff --git a/docs/shell_management.md b/docs/shell_management.md deleted file mode 100644 index 7579d96..0000000 --- a/docs/shell_management.md +++ /dev/null @@ -1,6 +0,0 @@ -# Shell Management - -Refer to the [smp Shell Management documentation](https://jphutchins.github.io/smp/latest/shell_management/) -for a complete description of each Request and Response. - -::: smpclient.requests.shell_management \ No newline at end of file diff --git a/docs/statistics_management.md b/docs/statistics_management.md deleted file mode 100644 index 2fb495c..0000000 --- a/docs/statistics_management.md +++ /dev/null @@ -1,6 +0,0 @@ -# Statistics Management - -Refer to the [smp Statistics Management documentation](https://jphutchins.github.io/smp/latest/statistics_management/) -for a complete description of each Request and Response. - -::: smpclient.requests.statistics_management \ No newline at end of file diff --git a/docs/user/intercreate.md b/docs/user/intercreate.md index 340ea19..87368c4 100644 --- a/docs/user/intercreate.md +++ b/docs/user/intercreate.md @@ -3,4 +3,4 @@ Refer to the [smp Intercreate documentation](https://jphutchins.github.io/smp/latest/user/intercreate) for a complete description of each Request and Response. -::: smpclient.requests.user.intercreate \ No newline at end of file +::: smpclient.extensions.intercreate diff --git a/docs/zephyr_management.md b/docs/zephyr_management.md deleted file mode 100644 index bcbe0ed..0000000 --- a/docs/zephyr_management.md +++ /dev/null @@ -1,6 +0,0 @@ -# Zephyr Management - -Refer to the [smp Zephyr Management documentation](https://jphutchins.github.io/smp/latest/zephyr_management/) -for a complete description of each Request and Response. - -::: smpclient.requests.zephyr_management \ No newline at end of file diff --git a/examples/ble/helloworld.py b/examples/ble/helloworld.py index c610e5d..b9c0341 100644 --- a/examples/ble/helloworld.py +++ b/examples/ble/helloworld.py @@ -3,9 +3,9 @@ import asyncio from typing import Final -from smpclient import SMPClient -from smpclient.generics import error, success -from smpclient.requests.os_management import EchoWrite +from smp.os_management import EchoWriteRequest + +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport @@ -20,7 +20,7 @@ async def main() -> None: print("OK") print("Sending request...", end="", flush=True) - response: Final = await client.request(EchoWrite(d="Hello, World!")) + response: Final = await client.request(EchoWriteRequest(d="Hello, World!")) print("OK") if success(response): diff --git a/examples/ble/imagestate.py b/examples/ble/imagestate.py index ed38a19..f9b8f30 100644 --- a/examples/ble/imagestate.py +++ b/examples/ble/imagestate.py @@ -3,9 +3,9 @@ import asyncio from typing import Final -from smpclient import SMPClient -from smpclient.generics import error, success -from smpclient.requests.image_management import ImageStatesRead +from smp.image_management import ImageStatesReadRequest + +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport @@ -20,7 +20,7 @@ async def main() -> None: print("OK") print("Sending request...", end="", flush=True) - response: Final = await client.request(ImageStatesRead()) + response: Final = await client.request(ImageStatesReadRequest()) print("OK") if success(response): diff --git a/examples/ble/mcumgrparameters.py b/examples/ble/mcumgrparameters.py index 8716ac5..57d0134 100644 --- a/examples/ble/mcumgrparameters.py +++ b/examples/ble/mcumgrparameters.py @@ -3,9 +3,9 @@ import asyncio from typing import Final -from smpclient import SMPClient -from smpclient.generics import error, success -from smpclient.requests.os_management import MCUMgrParametersRead +from smp.os_management import MCUMgrParametersReadRequest + +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport @@ -22,7 +22,7 @@ async def main() -> None: print(f"Client max unencoded size is {client._transport.max_unencoded_size}B") print("Sending request...", end="", flush=True) - response: Final = await client.request(MCUMgrParametersRead()) + response: Final = await client.request(MCUMgrParametersReadRequest()) print("OK") if success(response): diff --git a/examples/ble/upgrade.py b/examples/ble/upgrade.py index d3b38f6..1b68d19 100644 --- a/examples/ble/upgrade.py +++ b/examples/ble/upgrade.py @@ -10,12 +10,12 @@ from bleak import BleakScanner from bleak.backends.device import BLEDevice +from smp import SMPRequest +from smp.image_management import ImageStatesReadRequest, ImageStatesWriteRequest +from smp.os_management import ResetWriteRequest -from smpclient import SMPClient -from smpclient.generics import SMPRequest, TEr1, TEr2, TRep, error, success +from smpclient import SMPClient, TEr1, TEr2, TRep, error, success from smpclient.mcuboot import IMAGE_TLV, ImageInfo -from smpclient.requests.image_management import ImageStatesRead, ImageStatesWrite -from smpclient.requests.os_management import ResetWrite from smpclient.transport.ble import SMPBLETransport logging.basicConfig( @@ -80,7 +80,7 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: else: raise Exception(f"Unknown response: {response}") - response = await ensure_request(ImageStatesRead()) + response = await ensure_request(ImageStatesReadRequest()) assert response.images[0].hash == a_smp_dut_hash.value assert response.images[0].slot == 0 @@ -96,18 +96,18 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: print() - response = await ensure_request(ImageStatesRead()) + response = await ensure_request(ImageStatesReadRequest()) assert response.images[1].hash == b_smp_dut_hash.value assert response.images[1].slot == 1 print("Confirmed the upload") print() print("Marking B SMP DUT for test...") - await ensure_request(ImageStatesWrite(hash=response.images[1].hash)) + await ensure_request(ImageStatesWriteRequest(hash=response.images[1].hash)) print() print("Resetting for swap...") - await ensure_request(ResetWrite()) + await ensure_request(ResetWriteRequest()) print() print("Searching for B SMP DUT...", end="", flush=True) @@ -124,7 +124,7 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: print() print("Sending request...", end="", flush=True) - images = await client.request(ImageStatesRead()) + images = await client.request(ImageStatesReadRequest()) print("OK") if success(images): diff --git a/examples/ble/upload.py b/examples/ble/upload.py index b443530..025cb92 100644 --- a/examples/ble/upload.py +++ b/examples/ble/upload.py @@ -6,9 +6,9 @@ import time from typing import Final -from smpclient import SMPClient -from smpclient.generics import error, success -from smpclient.requests.image_management import ImageStatesRead +from smp.image_management import ImageStatesReadRequest + +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport logging.basicConfig( @@ -36,7 +36,7 @@ async def main() -> None: print("OK") print("Sending request...", end="", flush=True) - response = await client.request(ImageStatesRead()) + response = await client.request(ImageStatesReadRequest()) print("OK") if success(response): @@ -58,7 +58,7 @@ async def main() -> None: print() print("Sending request...", end="", flush=True) - response = await client.request(ImageStatesRead()) + response = await client.request(ImageStatesReadRequest()) print("OK") if success(response): diff --git a/examples/udp/helloworld.py b/examples/udp/helloworld.py index 9da2731..799f8db 100644 --- a/examples/udp/helloworld.py +++ b/examples/udp/helloworld.py @@ -5,9 +5,9 @@ import logging from typing import Final -from smpclient import SMPClient -from smpclient.generics import error, success -from smpclient.requests.os_management import EchoWrite +from smp.os_management import EchoWriteRequest + +from smpclient import SMPClient, error, success from smpclient.transport.udp import SMPUDPTransport logging.basicConfig(level=logging.DEBUG) @@ -22,7 +22,7 @@ async def main() -> None: print("OK") print("Sending request...", end="", flush=True) - response: Final = await client.request(EchoWrite(d="Hello, World!")) + response: Final = await client.request(EchoWriteRequest(d="Hello, World!")) print("OK") if success(response): diff --git a/examples/usb/helloworld.py b/examples/usb/helloworld.py index 9445b23..2373dd4 100644 --- a/examples/usb/helloworld.py +++ b/examples/usb/helloworld.py @@ -4,9 +4,9 @@ import asyncio from typing import Final -from smpclient import SMPClient -from smpclient.generics import error, success -from smpclient.requests.os_management import EchoWrite +from smp.os_management import EchoWriteRequest + +from smpclient import SMPClient, error, success from smpclient.transport.serial import SMPSerialTransport @@ -19,7 +19,7 @@ async def main() -> None: print("OK") print("Sending request...", end="", flush=True) - response: Final = await client.request(EchoWrite(d="Hello, World!")) + response: Final = await client.request(EchoWriteRequest(d="Hello, World!")) print("OK") if success(response): diff --git a/examples/usb/upgrade.py b/examples/usb/upgrade.py index 671e028..9cc0e45 100644 --- a/examples/usb/upgrade.py +++ b/examples/usb/upgrade.py @@ -10,14 +10,13 @@ from typing import Final from serial.tools.list_ports import comports +from smp import SMPRequest from smp import error as smperr -from smp.os_management import OS_MGMT_RET_RC +from smp.image_management import ImageStatesReadRequest, ImageStatesWriteRequest +from smp.os_management import OS_MGMT_RET_RC, ResetWriteRequest -from smpclient import SMPClient -from smpclient.generics import SMPRequest, TEr1, TEr2, TRep, error, error_v1, error_v2, success +from smpclient import SMPClient, TEr1, TEr2, TRep, error, error_v1, error_v2, success from smpclient.mcuboot import IMAGE_TLV, ImageInfo -from smpclient.requests.image_management import ImageStatesRead, ImageStatesWrite -from smpclient.requests.os_management import ResetWrite from smpclient.transport.serial import BufferParams, SMPSerialTransport logging.basicConfig( @@ -130,7 +129,7 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: else: raise Exception(f"Unknown response: {response}") - response = await ensure_request(ImageStatesRead()) + response = await ensure_request(ImageStatesReadRequest()) assert response.images[0].hash == a_smp_dut_hash.value assert response.images[0].slot == 0 @@ -155,7 +154,7 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: ) print() - response = await ensure_request(ImageStatesRead()) + response = await ensure_request(ImageStatesReadRequest()) assert response.images[1].hash == b_smp_dut_hash.value assert response.images[1].slot == 1 print("Confirmed the upload") @@ -167,11 +166,11 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: print() print("Marking B SMP DUT for test...") - await ensure_request(ImageStatesWrite(hash=response.images[1].hash)) + await ensure_request(ImageStatesWriteRequest(hash=response.images[1].hash)) print() print("Resetting for swap...") - reset_response = await client.request(ResetWrite()) + reset_response = await client.request(ResetWriteRequest()) if error_v1(reset_response): assert reset_response.rc == smperr.MGMT_ERR.EOK elif error_v2(reset_response): @@ -199,7 +198,7 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: print() print("Sending request...", end="", flush=True) - images = await client.request(ImageStatesRead()) + images = await client.request(ImageStatesReadRequest()) print("OK") if success(images): diff --git a/mkdocs.yaml b/mkdocs.yaml index b91d11c..baa7999 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -11,13 +11,6 @@ nav: - UDP (Network): transport/udp.md - MCUBoot: mcuboot.md - Request/Response: requests.md - - File Management: file_management.md - - Image Management: image_management.md - - OS Management: os_management.md - - Settings Management: settings_management.md - - Shell Management: shell_management.md - - Statistics Management: statistics_management.md - - Zephyr Management: zephyr_management.md - User: - Intercreate: user/intercreate.md diff --git a/pyproject.toml b/pyproject.toml index b3700be..c5d9db3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,12 @@ classifiers = [ "Operating System :: MacOS :: MacOS X", ] dependencies = [ -"smp>=4.1.0", +# TODO(screaming-goblin): re-pin to a released smp before this branch merges to main. +"smp @ git+https://github.com/JPHutchins/smp@screaming-goblin", +"msgspec>=0.21.1", "intelhex>=2.3.0", +# `TypeIs` landed in 4.10; `override`/`assert_never`/`deprecated` are older +"typing-extensions>=4.10", "async-timeout>=5.0.1; python_version < '3.11'", ] @@ -51,6 +55,10 @@ build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" +# TODO(screaming-goblin): drop with the `smp @ git+...` pin above. +[tool.hatch.metadata] +allow-direct-references = true + [dependency-groups] dev = [ "smpclient[all]", @@ -110,7 +118,9 @@ markers = [ [tool.coverage.run] source = ["smpclient", "tests"] branch = true -omit = ["tests/integration/*"] +# `test_generics_typing` asserts statically under mypy and pyright; it is never executed, +# so there is no runtime behaviour for coverage to measure. +omit = ["tests/integration/*", "tests/test_generics_typing.py"] [tool.coverage.report] fail_under = 91 diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index 9db4a55..1d81e14 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -36,26 +36,33 @@ """ import asyncio +import itertools import logging import traceback -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterator from hashlib import sha256 from types import TracebackType -from typing import Final, TypeVar +from typing import TYPE_CHECKING, Any, Final, TypeVar, Union, cast -from pydantic import ValidationError -from pydantic_core import ErrorDetails +import msgspec +from smp import SMPRequest +from smp import error as smperror from smp import header as smpheader from smp import message as smpmsg -from typing_extensions import assert_never +from smp.file_management import FileDownloadRequest, FileUploadRequest +from smp.image_management import ImageUploadWriteRequest +from smp.os_management import MCUMgrParametersReadRequest +from smp.user import intercreate as smpic +from typing_extensions import TypeIs, assert_never from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException -from smpclient.generics import SMPRequest, TEr1, TEr2, TRep, error, success -from smpclient.requests.file_management import FileDownload, FileUpload -from smpclient.requests.image_management import ImageUploadWrite -from smpclient.requests.os_management import MCUMgrParametersRead from smpclient.transport import SMPTransport +if TYPE_CHECKING: + from types_bits import u8 +else: # `types_bits` is typing-only; reaching its values at runtime needs its `rt` extra + u8 = int + try: from asyncio import timeout # type: ignore except ImportError: # backport for Python3.10 and below @@ -63,10 +70,82 @@ logger = logging.getLogger(__name__) -TUploadRequest = TypeVar("TUploadRequest", ImageUploadWrite, FileUpload) +TEr1 = TypeVar("TEr1", bound=smperror.ErrorV1) +"""Type of SMP Error V1.""" + +TEr2 = TypeVar("TEr2", bound=smperror.ErrorV2) +"""Type of SMP Error V2.""" + +TRep = TypeVar("TRep", bound=Union[smpmsg.ReadResponse, smpmsg.WriteResponse]) +"""Type of successful SMP Response (ReadResponse or WriteResponse).""" + + +def error_v1(response: smpmsg.Response) -> TypeIs[smperror.ErrorV1]: + """`TypeIs` that returns `True` if the `response` is an `ErrorV1`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is an `ErrorV1`. + """ + return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V1 + + +def error_v2(response: smpmsg.Response) -> TypeIs[smperror.ErrorV2[Any]]: + """`TypeIs` that returns `True` if the `response` is an `ErrorV2`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is an `ErrorV2`. + """ + return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V2 + + +def error( + response: smpmsg.Response, +) -> TypeIs[Union[smperror.ErrorV1, smperror.ErrorV2[Any]]]: + """`TypeIs` that returns `True` if the `response` is an `ErrorV1` or `ErrorV2`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is an `ErrorV1` or `ErrorV2`. + """ + return error_v1(response) or error_v2(response) + + +def success( + response: smpmsg.Response, +) -> TypeIs[Union[smpmsg.ReadResponse, smpmsg.WriteResponse]]: + """`TypeIs` that returns `True` if the `response` is a successful `Response`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is a successful `Response`. + """ + return response.RESPONSE_TYPE == smpmsg.ResponseType.SUCCESS + + +TUploadRequest = TypeVar( + "TUploadRequest", + ImageUploadWriteRequest, + FileUploadRequest, + smpic.ImageUploadWriteRequest, +) """A single-shot upload request whose `data` field is filled to maximize throughput.""" +def wrapping_sequence() -> Iterator[u8]: + """The default SMP sequence space: `0x00`-`0xFF`, wrapping.""" + return cast(Iterator[u8], itertools.cycle(range(0x100))) + + def _hexdump(frame: bytes) -> str: """Format `frame` as an offset/hex/printable-ASCII dump for readable debug logging.""" @@ -79,18 +158,10 @@ def row(offset: int) -> str: return "\n".join(row(offset) for offset in range(0, len(frame), 16)) -def _format_validation_error(error: ValidationError) -> str: - def row(detail: ErrorDetails) -> str: - location: Final = ".".join(str(part) for part in detail["loc"]) - return f"\t\t[{detail['type']}] {detail['msg']}: {location}; input: {detail['input']}" - - return "\n".join(row(detail) for detail in error.errors()) - - def _validation_failure( header: smpheader.Header, frame: bytes, - errors: tuple[tuple[type[smpmsg.Response], ValidationError], ...], + errors: tuple[tuple[type[smpmsg.Response], msgspec.DecodeError], ...], ) -> tuple[str, str]: """Return the `(summary, details)` describing why `frame` matched none of `errors`' types.""" summary: Final = ( @@ -103,8 +174,7 @@ def _validation_failure( f"Frame:\n{_hexdump(frame)}", "Errors:", *( - f"\tCould not be parsed as {response.__name__} because " - f"{len(error.errors())} error(s):\n{_format_validation_error(error)}" + f"\tCould not be parsed as {response.__name__}: {error}" for response, error in errors ), ) @@ -126,17 +196,18 @@ class SMPClient: transport: the `SMPTransport` to use address: the address of the SMP server, see `smpclient.transport` for details timeout_s: the default timeout in seconds for SMP requests + sequence: this client's SMP sequence space; defaults to `wrapping_sequence()` Example: ```python import asyncio from smpclient import SMPClient - from smpclient.requests.os_management import EchoWrite + from smp.os_management import EchoWriteRequest from smpclient.transport.ble import SMPBLETransport async def main(): async with SMPClient(SMPBLETransport(), "00:11:22:33:44:55") as client: - response = await client.request(EchoWrite(d="Hello, World!")) + response = await client.request(EchoWriteRequest(d="Hello, World!")) if success(response): print(f"Response: {response=}") @@ -148,10 +219,17 @@ async def main(): ``` """ - def __init__(self, transport: SMPTransport, address: str, timeout_s: float = 2.5): # noqa: DOC301 + def __init__( # noqa: DOC301 + self, + transport: SMPTransport, + address: str, + timeout_s: float = 2.5, + sequence: Iterator[u8] | None = None, + ): self._transport: Final = transport self._address: Final = address self._timeout_s = timeout_s + self._sequence: Final = wrapping_sequence() if sequence is None else sequence async def connect(self, connect_timeout_s: float | None = None) -> None: """Connect to the SMP server. @@ -189,7 +267,7 @@ async def request( Usage: ```python - response = await client.request(EchoWrite(d="Hello, World!")) + response = await client.request(EchoWriteRequest(d="Hello, World!")) if success(response): print(f"Response: {response=}") elif error(response): @@ -201,7 +279,7 @@ async def request( Type Safety and Exhaustiveness with Generic Typing: ```python - response = await client.request(EchoWrite(d="Hello, World!")) + response = await client.request(EchoWriteRequest(d="Hello, World!")) reveal_type(response) # Revealed type is 'Union[EchoWriteResponse, EchoWriteErrorV1, EchoWriteErrorV2]' if success(response): @@ -225,9 +303,11 @@ async def request( """ timeout_s = timeout_s if timeout_s is not None else self._timeout_s + request_frame: Final = request.to_frame(next(self._sequence)) + try: async with timeout(timeout_s): - frame = await self._transport.send_and_receive(request.BYTES) + frame = await self._transport.send_and_receive(bytes(request_frame)) except asyncio.TimeoutError: timeout_message: Final = f"Timeout ({timeout_s}s) waiting for request {request}" logger.error(timeout_message) @@ -235,23 +315,25 @@ async def request( header = smpheader.Header.loads(frame[: smpheader.Header.SIZE]) - if header.sequence != request.header.sequence: + if header.sequence != request_frame.header.sequence: raise SMPBadSequence( - f"Bad sequence {header.sequence}, expected {request.header.sequence}" + f"Bad sequence {header.sequence}, expected {request_frame.header.sequence}" ) - errors: list[tuple[type[smpmsg.Response], ValidationError]] = [] + # `SMPMalformed` and `SMPMismatchedGroupId` are not caught: they fail all three + # candidates identically, so they are transport errors rather than a mismatch. + errors: list[tuple[type[smpmsg.Response], msgspec.DecodeError]] = [] try: - return request._Response.loads(frame) # type: ignore[return-value] - except ValidationError as error: + return request._Response.loads(frame).data # type: ignore[return-value] + except msgspec.DecodeError as error: errors.append((request._Response, error)) try: - return request._ErrorV1.loads(frame) - except ValidationError as error: + return request._ErrorV1.loads(frame).data + except msgspec.DecodeError as error: errors.append((request._ErrorV1, error)) try: - return request._ErrorV2.loads(frame) - except ValidationError as error: + return request._ErrorV2.loads(frame).data + except msgspec.DecodeError as error: errors.append((request._ErrorV2, error)) summary, details = _validation_failure(header, frame, tuple(errors)) @@ -278,10 +360,10 @@ async def upload( confirmed from within the upgraded application. Zephyr provides [boot_write_img_confirmed()](https://docs.zephyrproject.org/apidoc/latest/group__mcuboot__api.html#ga95ccc9e1c7460fec16b9ce9ac8ad7a72) for this purpose. - first_timeout_s: the timeout for the first `ImageUploadWrite` request + first_timeout_s: the timeout for the first `ImageUploadWriteRequest` request which might take longer than subsequent requests (e.g. if a big chunk of flash memory has to be erased upfront). - subsequent_timeout_s: the timeout for subsequent `ImageUploadWrite` requests + subsequent_timeout_s: the timeout for subsequent `ImageUploadWriteRequest` requests use_sha: `True` to include the SHA256 hash of the image in the first packet. @@ -301,7 +383,7 @@ async def upload( response = await self.request( self._maximize_upload_packet( - ImageUploadWrite( + ImageUploadWriteRequest( off=0, data=b"", image=slot, @@ -327,7 +409,7 @@ async def upload( while response.off != len(image): response = await self.request( self._maximize_upload_packet( - ImageUploadWrite( + ImageUploadWriteRequest( off=response.off, data=b"", len=len(image) if response.off == 0 else None, @@ -367,7 +449,7 @@ async def upload_file( Args: file_data: the `bytes` to upload file_path: the path to upload to - timeout_s: the timeout for each `FileUpload` request + timeout_s: the timeout for each `FileUploadRequest` request Yields: int: the offset of the file upload @@ -379,7 +461,7 @@ async def upload_file( response = await self.request( self._maximize_upload_packet( - FileUpload(name=file_path, off=0, data=b"", len=len(file_data)), + FileUploadRequest(name=file_path, off=0, data=b"", len=len(file_data)), file_data, ), timeout_s=timeout_s, @@ -398,7 +480,7 @@ async def upload_file( while response.off != len(file_data): response = await self.request( self._maximize_upload_packet( - FileUpload(name=file_path, off=response.off, data=b""), file_data + FileUploadRequest(name=file_path, off=response.off, data=b""), file_data ), timeout_s=timeout_s, ) @@ -420,7 +502,7 @@ async def download_file( Args: file_path: the path to download - timeout_s: the timeout for each `FileDownload` request + timeout_s: the timeout for each `FileDownloadRequest` request Returns: The downloaded file as `bytes` @@ -430,7 +512,9 @@ async def download_file( """ timeout_s = timeout_s if timeout_s is not None else self._timeout_s - response = await self.request(FileDownload(off=0, name=file_path), timeout_s=timeout_s) + response = await self.request( + FileDownloadRequest(off=0, name=file_path), timeout_s=timeout_s + ) file_length = 0 if error(response): @@ -447,7 +531,7 @@ async def download_file( # send chunks until the SMP server reports that the offset is at the end of the image while response.off + len(response.data) != file_length: response = await self.request( - FileDownload(off=response.off + len(response.data), name=file_path), + FileDownloadRequest(off=response.off + len(response.data), name=file_path), timeout_s=timeout_s, ) if error(response): @@ -501,8 +585,12 @@ def get_max_cbor_and_data_size(self, request: smpmsg.WriteRequest) -> tuple[int, max_data_bytes: maximum amount of raw payload that can be stuffed into the currently-empty data field. """ + encoded_request: Final = bytes(request) + # given empty data in the request, how many bytes are available for the data? - unencoded_bytes_available: Final = self._transport.max_unencoded_size - len(bytes(request)) + unencoded_bytes_available: Final = ( + self._transport.max_unencoded_size - smpheader.Header.SIZE - len(encoded_request) + ) # how many bytes are required to encode the data size? bytes_required_to_encode_data_size: Final = self._cbor_integer_size( @@ -514,7 +602,7 @@ def get_max_cbor_and_data_size(self, request: smpmsg.WriteRequest) -> tuple[int, data_size: Final = max(0, unencoded_bytes_available - bytes_required_to_encode_data_size) # the final CBOR size is the original header length plus the data size # plus the bytes required to encode the data size - cbor_size: Final = request.header.length + data_size + self._cbor_integer_size(data_size) + cbor_size: Final = len(encoded_request) + data_size + self._cbor_integer_size(data_size) return cbor_size, data_size @@ -523,42 +611,23 @@ def _maximize_upload_packet(self, request: TUploadRequest, data: bytes) -> TUplo Fills the transport's `max_unencoded_size` so the encoded frame put on the wire is as large as the server's reassembly buffer allows (best throughput). Works - for any single-shot upload request (`ImageUploadWrite`, `FileUpload`): only + for any single-shot upload request (`ImageUploadWriteRequest`, `FileUploadRequest`): only `header` (with the buffer-filling `length`) and `data` change; every other field is carried over from `request`. """ - h: Final = request.header - cbor_size, data_size = self.get_max_cbor_and_data_size(request) - - if data_size > len(data) - request.off: # final packet - data_size = len(data) - request.off - cbor_size = h.length + data_size + self._cbor_integer_size(data_size) - - carried_over: Final = { - field: getattr(request, field) - for field in type(request).model_fields - if field not in ("header", "version", "sequence", "smp_data", "data") - } - return type(request)( - header=smpheader.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=cbor_size, - group_id=h.group_id, - sequence=h.sequence, - command_id=h.command_id, - ), - data=data[request.off : request.off + data_size], - **carried_over, - ) + _, max_data_size = self.get_max_cbor_and_data_size(request) + data_size: Final = min(max_data_size, len(data) - request.off) + + return msgspec.structs.replace(request, data=data[request.off : request.off + data_size]) async def _initialize(self, timeout_s: float | None = None) -> None: """Gather initialization information from the SMP server.""" timeout_s = timeout_s if timeout_s is not None else self._timeout_s try: - mcumgr_parameters = await self.request(MCUMgrParametersRead(), timeout_s=timeout_s) + mcumgr_parameters = await self.request( + MCUMgrParametersReadRequest(), timeout_s=timeout_s + ) if success(mcumgr_parameters): logger.debug(f"MCUMgr parameters: {mcumgr_parameters}") self._transport.initialize(mcumgr_parameters.buf_size) diff --git a/src/smpclient/extensions/intercreate.py b/src/smpclient/extensions/intercreate.py index 1ce34e8..c8a7343 100644 --- a/src/smpclient/extensions/intercreate.py +++ b/src/smpclient/extensions/intercreate.py @@ -1,14 +1,11 @@ """Intercreate extensions of the `SMPClient`.""" from collections.abc import AsyncIterator -from typing import Final -from smp import header as smpheader +from smp.user import intercreate as ic -from smpclient import SMPClient +from smpclient import SMPClient, error, success from smpclient.exceptions import SMPUploadError -from smpclient.generics import error, success -from smpclient.requests.user import intercreate as ic class ICUploadClient(SMPClient): @@ -17,7 +14,7 @@ class ICUploadClient(SMPClient): async def ic_upload(self, data: bytes, image: int = 0) -> AsyncIterator[int]: """Iteratively upload `data` to the SMP server, yielding the offset.""" response = await self.request( - ic.ImageUploadWrite(off=0, data=b'', image=image, len=len(data)) + ic.ImageUploadWriteRequest(off=0, data=b'', image=image, len=len(data)) ) if error(response): @@ -30,7 +27,9 @@ async def ic_upload(self, data: bytes, image: int = 0) -> AsyncIterator[int]: # send chunks until the SMP server reports that the offset is at the end of the image while response.off != len(data): response = await self.request( - self._ic_maximize_packet(ic.ImageUploadWrite(off=response.off, data=b''), data) + self._maximize_upload_packet( + ic.ImageUploadWriteRequest(off=response.off, data=b''), data + ) ) if error(response): raise SMPUploadError(response) @@ -38,29 +37,3 @@ async def ic_upload(self, data: bytes, image: int = 0) -> AsyncIterator[int]: yield response.off else: # pragma: no cover raise Exception("Unreachable") - - def _ic_maximize_packet(self, request: ic.ImageUploadWrite, data: bytes) -> ic.ImageUploadWrite: - """Given an `ic.ImageUploadWrite` with empty `data`, return the largest packet possible.""" - h: Final = request.header - cbor_size, data_size = self.get_max_cbor_and_data_size(request) - - if data_size > len(data) - request.off: # final packet - data_size = len(data) - request.off - cbor_size = h.length + data_size + self._cbor_integer_size(data_size) - - return ic.ImageUploadWrite( - header=smpheader.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=cbor_size, - group_id=h.group_id, - sequence=h.sequence, - command_id=h.command_id, - ), - off=request.off, - data=data[request.off : request.off + data_size], - image=request.image, - len=request.len, - sha=request.sha, - ) diff --git a/src/smpclient/generics.py b/src/smpclient/generics.py deleted file mode 100644 index 89e7b3d..0000000 --- a/src/smpclient/generics.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Generics and Type Narrowing for SMP Requests and Responses.""" - -from typing import Protocol, TypeVar, Union - -from smp import error as smperror -from smp import header as smphdr -from smp import message as smpmessage -from typing_extensions import TypeIs - -TEr1 = TypeVar("TEr1", bound=smperror.ErrorV1) -"""Type of SMP Error V1.""" - -TEr2 = TypeVar("TEr2", bound=smperror.ErrorV2) -"""Type of SMP Error V2.""" - -TRep = TypeVar("TRep", bound=Union[smpmessage.ReadResponse, smpmessage.WriteResponse]) -"""Type of successful SMP Response (ReadResponse or WriteResponse).""" - - -class SMPRequest(Protocol[TRep, TEr1, TEr2]): - """A `Protocol` that groups the expected response and errors with a request. - - To use, inherit from an SMP Read or Write `Request` and define its expected - `Response`, `ErrorV1`, and `ErrorV2`. - - Example: - ```python - class ImageStatesRead(smpimg.ImageStatesReadRequest): - _Response = smpimg.ImageStatesReadResponse - _ErrorV1 = smpimg.ImageManagementErrorV1 - _ErrorV2 = smpimg.ImageManagementErrorV2 - ``` - """ - - _Response: type[TRep] - _ErrorV1: type[TEr1] - _ErrorV2: type[TEr2] - - @property - def BYTES(self) -> bytes: # pragma: no cover - ... - - @property - def header(self) -> smphdr.Header: # pragma: no cover - ... - - -def error_v1(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV1]: - """`TypeIs` that returns `True` if the `response` is an `ErrorV1`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is an `ErrorV1`. - """ - return response.RESPONSE_TYPE == smpmessage.ResponseType.ERROR_V1 - - -def error_v2(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[TEr2]: - """`TypeIs` that returns `True` if the `response` is an `ErrorV2`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is an `ErrorV2`. - """ - return response.RESPONSE_TYPE == smpmessage.ResponseType.ERROR_V2 - - -def error(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV1 | TEr2]: - """`TypeIs` that returns `True` if the `response` is an `ErrorV1` or `ErrorV2`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is an `ErrorV1` or `ErrorV2`. - """ - return error_v1(response) or error_v2(response) - - -def success(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[TRep]: - """`TypeIs` that returns `True` if the `response` is a successful `Response`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is a successful `Response`. - """ - return response.RESPONSE_TYPE == smpmessage.ResponseType.SUCCESS diff --git a/src/smpclient/requests/__init__.py b/src/smpclient/requests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/smpclient/requests/enumeration_management.py b/src/smpclient/requests/enumeration_management.py deleted file mode 100644 index be340a7..0000000 --- a/src/smpclient/requests/enumeration_management.py +++ /dev/null @@ -1,22 +0,0 @@ -from smp import enumeration_management as smpem - - -class _EnumGroupBase: - _ErrorV1 = smpem.EnumManagementErrorV1 - _ErrorV2 = smpem.EnumManagementErrorV2 - - -class CountSupportedGroups(smpem.GroupCountRequest, _EnumGroupBase): - _Response = smpem.GroupCountResponse - - -class ListSupportedGroups(smpem.ListOfGroupsRequest, _EnumGroupBase): - _Response = smpem.ListOfGroupsResponse - - -class GroupId(smpem.GroupIdRequest, _EnumGroupBase): - _Response = smpem.GroupIdResponse - - -class GroupDetails(smpem.GroupDetailsRequest, _EnumGroupBase): - _Response = smpem.GroupDetailsResponse diff --git a/src/smpclient/requests/file_management.py b/src/smpclient/requests/file_management.py deleted file mode 100644 index 06fb5d0..0000000 --- a/src/smpclient/requests/file_management.py +++ /dev/null @@ -1,30 +0,0 @@ -from smp import file_management as smpfs - - -class _FileGroupBase: - _ErrorV1 = smpfs.FileSystemManagementErrorV1 - _ErrorV2 = smpfs.FileSystemManagementErrorV2 - - -class FileDownload(smpfs.FileDownloadRequest, _FileGroupBase): - _Response = smpfs.FileDownloadResponse - - -class FileUpload(smpfs.FileUploadRequest, _FileGroupBase): - _Response = smpfs.FileUploadResponse - - -class FileStatus(smpfs.FileStatusRequest, _FileGroupBase): - _Response = smpfs.FileStatusResponse - - -class FileHashChecksum(smpfs.FileHashChecksumRequest, _FileGroupBase): - _Response = smpfs.FileHashChecksumResponse - - -class SupportedFileHashChecksumTypes(smpfs.SupportedFileHashChecksumTypesRequest, _FileGroupBase): - _Response = smpfs.SupportedFileHashChecksumTypesResponse - - -class FileClose(smpfs.FileCloseRequest, _FileGroupBase): - _Response = smpfs.FileCloseResponse diff --git a/src/smpclient/requests/image_management.py b/src/smpclient/requests/image_management.py deleted file mode 100644 index 2e31306..0000000 --- a/src/smpclient/requests/image_management.py +++ /dev/null @@ -1,22 +0,0 @@ -from smp import image_management as smpimg - - -class _ImageGroupBase: - _ErrorV1 = smpimg.ImageManagementErrorV1 - _ErrorV2 = smpimg.ImageManagementErrorV2 - - -class ImageStatesRead(smpimg.ImageStatesReadRequest, _ImageGroupBase): - _Response = smpimg.ImageStatesReadResponse - - -class ImageStatesWrite(smpimg.ImageStatesWriteRequest, _ImageGroupBase): - _Response = smpimg.ImageStatesWriteResponse - - -class ImageUploadWrite(smpimg.ImageUploadWriteRequest, _ImageGroupBase): - _Response = smpimg.ImageUploadWriteResponse - - -class ImageErase(smpimg.ImageEraseRequest, _ImageGroupBase): - _Response = smpimg.ImageEraseResponse diff --git a/src/smpclient/requests/os_management.py b/src/smpclient/requests/os_management.py deleted file mode 100644 index cea7a89..0000000 --- a/src/smpclient/requests/os_management.py +++ /dev/null @@ -1,42 +0,0 @@ -from smp import os_management as smpos - - -class _OSGroupBase: - _ErrorV1 = smpos.OSManagementErrorV1 - _ErrorV2 = smpos.OSManagementErrorV2 - - -class EchoWrite(smpos.EchoWriteRequest, _OSGroupBase): - _Response = smpos.EchoWriteResponse - - -class ResetWrite(smpos.ResetWriteRequest, _OSGroupBase): - _Response = smpos.ResetWriteResponse - - -class TaskStatisticsRead(smpos.TaskStatisticsReadRequest, _OSGroupBase): - _Response = smpos.TaskStatisticsReadResponse - - -class MemoryPoolStatisticsRead(smpos.MemoryPoolStatisticsReadRequest, _OSGroupBase): - _Response = smpos.MemoryPoolStatisticsReadResponse - - -class DateTimeRead(smpos.DateTimeReadRequest, _OSGroupBase): - _Response = smpos.DateTimeReadResponse - - -class DateTimeWrite(smpos.DateTimeWriteRequest, _OSGroupBase): - _Response = smpos.DateTimeWriteResponse - - -class MCUMgrParametersRead(smpos.MCUMgrParametersReadRequest, _OSGroupBase): - _Response = smpos.MCUMgrParametersReadResponse - - -class OSApplicationInfoRead(smpos.OSApplicationInfoReadRequest, _OSGroupBase): - _Response = smpos.OSApplicationInfoReadResponse - - -class BootloaderInformationRead(smpos.BootloaderInformationReadRequest, _OSGroupBase): - _Response = smpos.BootloaderInformationReadResponse diff --git a/src/smpclient/requests/settings_management.py b/src/smpclient/requests/settings_management.py deleted file mode 100644 index 121eeb1..0000000 --- a/src/smpclient/requests/settings_management.py +++ /dev/null @@ -1,30 +0,0 @@ -import smp.settings_management as smpset - - -class _GroupBase: - _ErrorV1 = smpset.SettingsManagementErrorV1 - _ErrorV2 = smpset.SettingsManagementErrorV2 - - -class ReadSetting(smpset.ReadSettingRequest, _GroupBase): - _Response = smpset.ReadSettingResponse - - -class WriteSetting(smpset.WriteSettingRequest, _GroupBase): - _Response = smpset.WriteSettingResponse - - -class DeleteSetting(smpset.DeleteSettingRequest, _GroupBase): - _Response = smpset.DeleteSettingResponse - - -class CommitSettings(smpset.CommitSettingsRequest, _GroupBase): - _Response = smpset.CommitSettingsResponse - - -class LoadSettings(smpset.LoadSettingsRequest, _GroupBase): - _Response = smpset.LoadSettingsResponse - - -class SaveSettings(smpset.SaveSettingsRequest, _GroupBase): - _Response = smpset.SaveSettingsResponse diff --git a/src/smpclient/requests/shell_management.py b/src/smpclient/requests/shell_management.py deleted file mode 100644 index 27d9714..0000000 --- a/src/smpclient/requests/shell_management.py +++ /dev/null @@ -1,10 +0,0 @@ -from smp import shell_management as smpshell - - -class _ShellGroupBase: - _ErrorV1 = smpshell.ShellManagementErrorV1 - _ErrorV2 = smpshell.ShellManagementErrorV2 - - -class Execute(smpshell.ExecuteRequest, _ShellGroupBase): - _Response = smpshell.ExecuteResponse diff --git a/src/smpclient/requests/statistics_management.py b/src/smpclient/requests/statistics_management.py deleted file mode 100644 index c48a946..0000000 --- a/src/smpclient/requests/statistics_management.py +++ /dev/null @@ -1,14 +0,0 @@ -import smp.statistics_management as smpstat - - -class _GroupBase: - _ErrorV1 = smpstat.StatisticsManagementErrorV1 - _ErrorV2 = smpstat.StatisticsManagementErrorV2 - - -class GroupData(smpstat.GroupDataRequest, _GroupBase): - _Response = smpstat.GroupDataResponse - - -class ListOfGroups(smpstat.ListOfGroupsRequest, _GroupBase): - _Response = smpstat.ListOfGroupsResponse diff --git a/src/smpclient/requests/user/__init__.py b/src/smpclient/requests/user/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/smpclient/requests/user/intercreate.py b/src/smpclient/requests/user/intercreate.py deleted file mode 100644 index 39f520c..0000000 --- a/src/smpclient/requests/user/intercreate.py +++ /dev/null @@ -1,10 +0,0 @@ -from smp.user import intercreate as smpic - - -class _GroupBase: - _ErrorV1 = smpic.ErrorV1 - _ErrorV2 = smpic.ErrorV2 - - -class ImageUploadWrite(smpic.ImageUploadWriteRequest, _GroupBase): - _Response = smpic.ImageUploadWriteResponse diff --git a/src/smpclient/requests/zephyr_management.py b/src/smpclient/requests/zephyr_management.py deleted file mode 100644 index 9acbe51..0000000 --- a/src/smpclient/requests/zephyr_management.py +++ /dev/null @@ -1,10 +0,0 @@ -import smp.zephyr_management as smpz - - -class _GroupBase: - _ErrorV1 = smpz.ZephyrManagementErrorV1 - _ErrorV2 = smpz.ZephyrManagementErrorV2 - - -class EraseStorage(smpz.EraseStorageRequest, _GroupBase): - _Response = smpz.EraseStorageResponse diff --git a/src/smpclient/transport/bumble/__main__.py b/src/smpclient/transport/bumble/__main__.py index 67a219d..06e3892 100644 --- a/src/smpclient/transport/bumble/__main__.py +++ b/src/smpclient/transport/bumble/__main__.py @@ -9,11 +9,10 @@ import sys from typing import Final, NamedTuple +from smp.os_management import EchoWriteRequest from typing_extensions import assert_never -from smpclient import SMPClient -from smpclient.generics import error, success -from smpclient.requests.os_management import EchoWrite +from smpclient import SMPClient, error, success from smpclient.transport.bumble import SMPBumbleTransport from smpclient.transport.bumble.pairing import ( KeyboardOnly, @@ -97,7 +96,7 @@ async def _echo(args: _EchoArgs) -> int: async with SMPClient( SMPBumbleTransport(hci=args.hci), args.address, timeout_s=args.timeout ) as client: - response = await client.request(EchoWrite(d=args.message)) + response = await client.request(EchoWriteRequest(d=args.message)) if success(response): print(response.r) return 0 diff --git a/tests/extensions/test_intercreate.py b/tests/extensions/test_intercreate.py index 5b0c9e6..00d7c16 100644 --- a/tests/extensions/test_intercreate.py +++ b/tests/extensions/test_intercreate.py @@ -5,10 +5,10 @@ import pytest from smp import packet as smppacket +from smp.user import intercreate as ic from smp.user import intercreate as smpic from smpclient.extensions.intercreate import ICUploadClient -from smpclient.requests.user import intercreate as ic from smpclient.transport.serial import SMPSerialTransport @@ -39,11 +39,11 @@ def mock_write(data: bytes) -> int: s._transport._conn.write = mock_write # type: ignore type(s._transport._conn).out_waiting = 0 # type: ignore - async def mock_request(request: ic.ImageUploadWrite) -> smpic.ImageUploadWriteResponse: + async def mock_request(request: ic.ImageUploadWriteRequest) -> smpic.ImageUploadWriteResponse: # call the real send method (with write mocked) but don't bother with receive # this does provide coverage for the MTU-limited encoding done in the send method - await s._transport.send(request.BYTES) - return ic.ImageUploadWrite._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa + await s._transport.send(bytes(request.to_frame(sequence=0))) + return ic.ImageUploadWriteResponse(off=request.off + len(request.data)) s.request = mock_request # type: ignore @@ -63,7 +63,7 @@ async def mock_request(request: ic.ImageUploadWrite) -> smpic.ImageUploadWriteRe try: decoder.send(packet) except StopIteration as e: - reconstructed_request = smpic.ImageUploadWriteRequest.loads(e.value) + reconstructed_request = smpic.ImageUploadWriteRequest.loads(e.value).data reconstructed_image.extend(reconstructed_request.data) decoder = smppacket.decode() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 24f9302..645e250 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,14 +20,12 @@ import pytest import pytest_asyncio from _pytest.mark.structures import ParameterSet -from smp.os_management import BootMode +from smp.image_management import ImageStatesReadRequest +from smp.os_management import BootMode, EchoWriteRequest, ResetWriteRequest from typing_extensions import assert_never -from smpclient import SMPClient +from smpclient import SMPClient, success from smpclient.exceptions import SMPBadSequence -from smpclient.generics import success -from smpclient.requests.image_management import ImageStatesRead -from smpclient.requests.os_management import EchoWrite, ResetWrite from smpclient.transport import SMPTransport from smpclient.transport.serial import SMPSerialRawTransport, SMPSerialTransport from smpclient.transport.udp import SMPUDPTransport @@ -144,7 +142,7 @@ async def _wait_until_answering(client: SMPClient, *, attempts: int = 30) -> Non """Round-trip an echo until the server answers, tolerating boot-time stalls.""" async def echoes(c: SMPClient) -> bool: - response = await c.request(EchoWrite(d=_READY_PROBE), timeout_s=1.0) + response = await c.request(EchoWriteRequest(d=_READY_PROBE), timeout_s=1.0) return success(response) and response.r == _READY_PROBE if not await _poll_until_answering(client, echoes, attempts=attempts): @@ -245,10 +243,12 @@ async def reboot_into_recovery( `transport` then connects to the bootloader on the same serial endpoint, probed until it answers (the recovery server speaks the img group, not echo). """ - assert success(await app_client.request(ImageStatesRead())) + assert success(await app_client.request(ImageStatesReadRequest())) try: assert success( - await app_client.request(ResetWrite(boot_mode=BootMode.BOOTLOADER), timeout_s=3.0) + await app_client.request( + ResetWriteRequest(boot_mode=BootMode.BOOTLOADER), timeout_s=3.0 + ) ) except TimeoutError: pass # some servers reset before sending the response @@ -256,7 +256,7 @@ async def reboot_into_recovery( await asyncio.sleep(2.0) # let MCUboot serial recovery come up async def lists_images(c: SMPClient) -> bool: - return success(await c.request(ImageStatesRead(), timeout_s=1.0)) + return success(await c.request(ImageStatesReadRequest(), timeout_s=1.0)) bootloader = SMPClient(transport, address) await bootloader.connect() diff --git a/tests/integration/test_enumeration.py b/tests/integration/test_enumeration.py index 96ffe74..e1fb254 100644 --- a/tests/integration/test_enumeration.py +++ b/tests/integration/test_enumeration.py @@ -4,21 +4,21 @@ import pytest from smp import header as smphdr +from smp.enumeration_management import GroupCountRequest, ListOfGroupsRequest -from smpclient.generics import success -from smpclient.requests.enumeration_management import CountSupportedGroups, ListSupportedGroups +from smpclient import success from tests.integration.conftest import ConnectedServer pytestmark = [pytest.mark.integration, pytest.mark.asyncio] async def test_count_supported_groups(connected_server: ConnectedServer) -> None: - response = await connected_server.client.request(CountSupportedGroups()) + response = await connected_server.client.request(GroupCountRequest()) assert success(response) assert response.count > 0 async def test_os_group_is_supported(connected_server: ConnectedServer) -> None: - response = await connected_server.client.request(ListSupportedGroups()) + response = await connected_server.client.request(ListOfGroupsRequest()) assert success(response) assert smphdr.GroupId.OS_MANAGEMENT in response.groups diff --git a/tests/integration/test_fragmentation.py b/tests/integration/test_fragmentation.py index c43020e..a028cc3 100644 --- a/tests/integration/test_fragmentation.py +++ b/tests/integration/test_fragmentation.py @@ -10,10 +10,9 @@ import pytest from smp import packet as smppacket +from smp.os_management import EchoWriteRequest -from smpclient import SMPClient -from smpclient.generics import success -from smpclient.requests.os_management import EchoWrite +from smpclient import SMPClient, success from smpclient.transport.serial import BufferParams, SMPSerialTransport from tests.integration.conftest import ConnectedServer, _wait_until_answering, fixture_params from tests.integration.servers import PtyEndpoint, ServerFixture, serve @@ -51,7 +50,7 @@ async def test_noparams_falls_back_to_defaults(connected_server: ConnectedServer assert transport._line_buffers == 2 assert transport.mtu == transport._line_length * transport._line_buffers - response = await cs.client.request(EchoWrite(d="fallback works")) + response = await cs.client.request(EchoWriteRequest(d="fallback works")) assert success(response) assert response.r == "fallback works" @@ -69,7 +68,7 @@ async def test_two_fragment_roundtrip(connected_server: ConnectedServer) -> None text = "Z" * transport._line_length # > one line packet, within a 2-fragment burst # Generous timeout: a multi-fragment round-trip is slow on emulated MCUs under CI load. - response = await cs.client.request(EchoWrite(d=text), timeout_s=10.0) + response = await cs.client.request(EchoWriteRequest(d=text), timeout_s=10.0) assert success(response) assert response.r == text @@ -89,11 +88,19 @@ async def test_max_payload_roundtrip(connected_server: ConnectedServer) -> None: transport = cs.client._transport assert isinstance(transport, SMPSerialTransport) - text = "M" * (transport.max_unencoded_size - len(EchoWrite(d="").BYTES) - 4) - request = EchoWrite(d=text) - assert len(request.BYTES) <= transport.max_unencoded_size - - line_packets = len(list(smppacket.encode(request.BYTES, line_length=transport._line_length))) + text = "M" * ( + transport.max_unencoded_size - len(bytes(EchoWriteRequest(d="").to_frame(sequence=0))) - 4 + ) + request = EchoWriteRequest(d=text) + assert len(bytes(request.to_frame(sequence=0))) <= transport.max_unencoded_size + + line_packets = len( + list( + smppacket.encode( + bytes(request.to_frame(sequence=0)), line_length=transport._line_length + ) + ) + ) limit = cs.fixture.max_reliable_line_packets if limit is not None and line_packets > limit: pytest.skip( @@ -127,10 +134,10 @@ async def test_non_default_line_length(fixture: ServerFixture) -> None: assert transport._line_length == 512 text = "L" * 200 # > one 128-byte line packet, so the 512 line length is in effect - request = EchoWrite(d=text) - assert len(list(smppacket.encode(request.BYTES, line_length=512))) < len( - list(smppacket.encode(request.BYTES, line_length=128)) - ) + request = EchoWriteRequest(d=text) + assert len( + list(smppacket.encode(bytes(request.to_frame(sequence=0)), line_length=512)) + ) < len(list(smppacket.encode(bytes(request.to_frame(sequence=0)), line_length=128))) response = await client.request(request, timeout_s=10.0) assert success(response) assert response.r == text diff --git a/tests/integration/test_image_management.py b/tests/integration/test_image_management.py index f80914d..e8b9399 100644 --- a/tests/integration/test_image_management.py +++ b/tests/integration/test_image_management.py @@ -9,10 +9,10 @@ import pytest from smp import packet as smppacket +from smp.image_management import ImageStatesReadRequest -from smpclient.generics import success +from smpclient import success from smpclient.mcuboot import IMAGE_TLV, ImageInfo -from smpclient.requests.image_management import ImageStatesRead from smpclient.transport.serial import SMPSerialRawTransport, SMPSerialTransport from tests.integration.conftest import ( assert_chunks_maximized, @@ -32,7 +32,7 @@ @pytest.mark.parametrize("fixture", fixture_params(lambda f: f.has_group("img"))) async def test_image_states_read(fixture: ServerFixture) -> None: async with connected(fixture) as cs: - response = await cs.client.request(ImageStatesRead()) + response = await cs.client.request(ImageStatesReadRequest()) assert success(response) assert len(response.images) >= 1 assert response.images[0].active is True @@ -69,7 +69,7 @@ async def test_dfu_upload(fixture: ServerFixture) -> None: if cap is None: assert offsets[-1] == len(image) # ran to completion (incl. SHA match) - states = await cs.client.request(ImageStatesRead()) + states = await cs.client.request(ImageStatesReadRequest()) assert success(states) uploaded_hash = ( ImageInfo.load_file(str(signed_image(fixture))).get_tlv(IMAGE_TLV.SHA256).value diff --git a/tests/integration/test_os_management.py b/tests/integration/test_os_management.py index 088d127..d86d572 100644 --- a/tests/integration/test_os_management.py +++ b/tests/integration/test_os_management.py @@ -3,9 +3,9 @@ from __future__ import annotations import pytest +from smp.os_management import EchoWriteRequest, MCUMgrParametersReadRequest -from smpclient.generics import success -from smpclient.requests.os_management import EchoWrite, MCUMgrParametersRead +from smpclient import success from tests.integration.conftest import ConnectedServer pytestmark = [pytest.mark.integration, pytest.mark.asyncio] @@ -13,7 +13,7 @@ @pytest.mark.parametrize("text", ["", "a", "Hello, SMP server!"]) async def test_echo_roundtrip(connected_server: ConnectedServer, text: str) -> None: - response = await connected_server.client.request(EchoWrite(d=text)) + response = await connected_server.client.request(EchoWriteRequest(d=text)) assert success(response) assert response.r == text @@ -23,7 +23,7 @@ async def test_mcumgr_parameters(connected_server: ConnectedServer) -> None: if not fixture.params_supported: pytest.skip("MCUmgr params command disabled on this fixture") - response = await connected_server.client.request(MCUMgrParametersRead()) + response = await connected_server.client.request(MCUMgrParametersReadRequest()) assert success(response) # The server reports exactly what the vendored manifest claims. assert response.buf_size == fixture.buf_size diff --git a/tests/integration/test_serial_recovery.py b/tests/integration/test_serial_recovery.py index 292dd4f..aae2b39 100644 --- a/tests/integration/test_serial_recovery.py +++ b/tests/integration/test_serial_recovery.py @@ -28,11 +28,11 @@ import pytest from _pytest.mark.structures import ParameterSet from smp import packet as smppacket +from smp.image_management import ImageStatesReadRequest +from smp.os_management import MCUMgrParametersReadRequest from typing_extensions import assert_never -from smpclient.generics import success -from smpclient.requests.image_management import ImageStatesRead -from smpclient.requests.os_management import MCUMgrParametersRead +from smpclient import success from smpclient.transport.serial import Auto, BufferSize, Cobs, SMPSerialTransport from smpclient.transport.serial.encoded import _FRAME_OVERHEAD from tests.integration.conftest import ( @@ -173,7 +173,7 @@ async def test_upload_to_mcuboot_recovery(variant: _Recovery, fixture: ServerFix async with reboot_into_recovery(cs.client, transport, cs.endpoint.url) as bootloader: await bootloader._initialize() # negotiate buf_size (a no-op for explicit BufferSize) - params = await bootloader.request(MCUMgrParametersRead(), timeout_s=2.0) + params = await bootloader.request(MCUMgrParametersReadRequest(), timeout_s=2.0) assert success(params) assert (params.buf_count, params.buf_size) == (1, advertised) @@ -188,6 +188,6 @@ async def test_upload_to_mcuboot_recovery(variant: _Recovery, fixture: ServerFix assert offsets[-1] >= 4096 # the bootloader reassembles the fragmented upload assert_chunks_maximized(offsets, cap) - states = await bootloader.request(ImageStatesRead(), timeout_s=5.0) + states = await bootloader.request(ImageStatesReadRequest(), timeout_s=5.0) assert success(states) assert len(states.images) >= 1 # the recovery img group is coherent after the upload diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 2d2af82..8b5a60c 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -3,9 +3,9 @@ from __future__ import annotations import pytest +from smp.os_management import EchoWriteRequest -from smpclient.generics import success -from smpclient.requests.os_management import EchoWrite +from smpclient import success from smpclient.transport.udp import SMPUDPTransport from tests.integration.conftest import ConnectedServer @@ -22,6 +22,6 @@ async def test_udp_ip_family(connected_server: ConnectedServer) -> None: assert isinstance(transport, SMPUDPTransport) assert transport._is_ipv6 == (cs.fixture.ip_family == "ipv6") - response = await cs.client.request(EchoWrite(d="udp family")) + response = await cs.client.request(EchoWriteRequest(d="udp family")) assert success(response) assert response.r == "udp family" diff --git a/tests/test_generics_typing.py b/tests/test_generics_typing.py new file mode 100644 index 0000000..c61f0c8 --- /dev/null +++ b/tests/test_generics_typing.py @@ -0,0 +1,123 @@ +"""Static request/response narrowing assertions for `SMPClient.request`. + +These functions are verified by mypy and pyright (the `typecheck` task); they are never +executed. They encode the exhaustiveness contract that `SMPClient.request` inherits from +`smp.SMPRequest`: the returned union narrows to exactly the request's `Response`, +`ErrorV1`, and `ErrorV2`, and `assert_never` proves nothing else remains. + +`assert_type` pins the narrowed type rather than merely asserting it is *a* response, so a +binding that silently widened -- the failure mode of a non-generic `TypeIs` -- fails here. +""" + +from smp import enumeration_management as enum +from smp import file_management as fs +from smp import image_management as img +from smp import os_management as os +from smp import settings_management as settings +from smp import shell_management as shell +from smp import statistics_management as stat +from smp import zephyr_management as zephyr +from smp.user import intercreate as ic +from typing_extensions import assert_never, assert_type + +from smpclient import SMPClient, error, error_v1, error_v2, success + + +async def _check_exhaustive_narrowing(client: SMPClient) -> None: + """The whole point: every arm is reachable and `assert_never` closes the union.""" + response = await client.request(os.EchoWriteRequest(d="hello")) + + if success(response): + assert_type(response, os.EchoWriteResponse) + elif error_v1(response): + assert_type(response, os.OSManagementErrorV1) + elif error_v2(response): + assert_type(response, os.OSManagementErrorV2) + else: + assert_never(response) + + +async def _check_error_groups_both_arms(client: SMPClient) -> None: + """`error` covers both error types, and narrows further to each.""" + response = await client.request(img.ImageStatesReadRequest()) + + if success(response): + assert_type(response, img.ImageStatesReadResponse) + elif error(response): + if error_v1(response): + assert_type(response, img.ImageManagementErrorV1) + elif error_v2(response): + assert_type(response, img.ImageManagementErrorV2) + else: + assert_never(response) + else: + assert_never(response) + + +async def _check_os_binding(client: SMPClient) -> None: + assert_type( + await client.request(os.ResetWriteRequest()), + os.ResetWriteResponse | os.OSManagementErrorV1 | os.OSManagementErrorV2, + ) + + +async def _check_image_binding(client: SMPClient) -> None: + assert_type( + await client.request(img.ImageUploadWriteRequest(off=0, data=b"")), + img.ImageUploadWriteResponse | img.ImageManagementErrorV1 | img.ImageManagementErrorV2, + ) + + +async def _check_file_binding(client: SMPClient) -> None: + assert_type( + await client.request(fs.FileDownloadRequest(off=0, name="f")), + fs.FileDownloadResponse | fs.FileSystemManagementErrorV1 | fs.FileSystemManagementErrorV2, + ) + + +async def _check_enumeration_binding(client: SMPClient) -> None: + assert_type( + await client.request(enum.GroupCountRequest()), + enum.GroupCountResponse | enum.EnumManagementErrorV1 | enum.EnumManagementErrorV2, + ) + + +async def _check_settings_binding(client: SMPClient) -> None: + assert_type( + await client.request(settings.ReadSettingRequest(name="n")), + settings.ReadSettingResponse + | settings.SettingsManagementErrorV1 + | settings.SettingsManagementErrorV2, + ) + + +async def _check_shell_binding(client: SMPClient) -> None: + assert_type( + await client.request(shell.ExecuteRequest(argv=["echo"])), + shell.ExecuteResponse | shell.ShellManagementErrorV1 | shell.ShellManagementErrorV2, + ) + + +async def _check_statistics_binding(client: SMPClient) -> None: + assert_type( + await client.request(stat.ListOfGroupsRequest()), + stat.ListOfGroupsResponse + | stat.StatisticsManagementErrorV1 + | stat.StatisticsManagementErrorV2, + ) + + +async def _check_zephyr_binding(client: SMPClient) -> None: + assert_type( + await client.request(zephyr.EraseStorageRequest()), + zephyr.EraseStorageResponse + | zephyr.ZephyrManagementErrorV1 + | zephyr.ZephyrManagementErrorV2, + ) + + +async def _check_intercreate_binding(client: SMPClient) -> None: + assert_type( + await client.request(ic.ImageUploadWriteRequest(off=0, data=b"")), + ic.ImageUploadWriteResponse | ic.ErrorV1 | ic.ErrorV2, + ) diff --git a/tests/test_requests.py b/tests/test_requests.py deleted file mode 100644 index cb93fd4..0000000 --- a/tests/test_requests.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Test the `SMPRequest` `Protocol` implementations.""" - -import pytest -from smp import enumeration_management as smpem -from smp import error as smperr -from smp import file_management as smpfs -from smp import header as smphdr -from smp import image_management as smpimg -from smp import message as smpmsg -from smp import os_management as smpos -from smp import settings_management as smpset -from smp import shell_management as smpsh -from smp import statistics_management as smpstat -from smp import zephyr_management as smpz -from smp.user import intercreate as smpic - -from smpclient.generics import SMPRequest, TEr1, TEr2, TRep -from smpclient.requests.enumeration_management import ( - CountSupportedGroups, - GroupDetails, - GroupId, - ListSupportedGroups, -) -from smpclient.requests.file_management import ( - FileClose, - FileDownload, - FileHashChecksum, - FileStatus, - FileUpload, - SupportedFileHashChecksumTypes, -) -from smpclient.requests.image_management import ImageStatesRead, ImageStatesWrite, ImageUploadWrite -from smpclient.requests.os_management import ( - BootloaderInformationRead, - DateTimeRead, - DateTimeWrite, - EchoWrite, - MCUMgrParametersRead, - MemoryPoolStatisticsRead, - OSApplicationInfoRead, - ResetWrite, - TaskStatisticsRead, -) -from smpclient.requests.settings_management import ( - CommitSettings, - DeleteSetting, - LoadSettings, - ReadSetting, - SaveSettings, - WriteSetting, -) -from smpclient.requests.shell_management import Execute -from smpclient.requests.statistics_management import GroupData, ListOfGroups -from smpclient.requests.user import intercreate as ic -from smpclient.requests.zephyr_management import EraseStorage - - -@pytest.mark.parametrize( - "test_tuple", - ( - ( - smpem.GroupCountRequest(), - CountSupportedGroups(), - smpem.GroupCountResponse, - smpem.EnumManagementErrorV1, - smpem.EnumManagementErrorV2, - ), - ( - smpem.ListOfGroupsRequest(), - ListSupportedGroups(), - smpem.ListOfGroupsResponse, - smpem.EnumManagementErrorV1, - smpem.EnumManagementErrorV2, - ), - ( - smpem.GroupIdRequest(), - GroupId(), - smpem.GroupIdResponse, - smpem.EnumManagementErrorV1, - smpem.EnumManagementErrorV2, - ), - ( - smpem.GroupDetailsRequest(), - GroupDetails(), - smpem.GroupDetailsResponse, - smpem.EnumManagementErrorV1, - smpem.EnumManagementErrorV2, - ), - ( - smpimg.ImageStatesReadRequest(), - ImageStatesRead(), - smpimg.ImageStatesReadResponse, - smpimg.ImageManagementErrorV1, - smpimg.ImageManagementErrorV2, - ), - ( - smpimg.ImageStatesWriteRequest(hash=b"da hash"), - ImageStatesWrite(hash=b"da hash"), - smpimg.ImageStatesWriteResponse, - smpimg.ImageManagementErrorV1, - smpimg.ImageManagementErrorV2, - ), - ( - smpimg.ImageUploadWriteRequest(off=0, data=b"a"), - ImageUploadWrite(off=0, data=b"a"), - smpimg.ImageUploadWriteResponse, - smpimg.ImageManagementErrorV1, - smpimg.ImageManagementErrorV2, - ), - ( - smpos.EchoWriteRequest(d="a"), - EchoWrite(d="a"), - smpos.EchoWriteResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.ResetWriteRequest(), - ResetWrite(), - smpos.ResetWriteResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.ResetWriteRequest(boot_mode=smpos.BootMode.BOOTLOADER), - ResetWrite(boot_mode=smpos.BootMode.BOOTLOADER), - smpos.ResetWriteResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.ResetWriteRequest(force=1, boot_mode=1), - ResetWrite(force=1, boot_mode=1), - smpos.ResetWriteResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpsh.ExecuteRequest(argv=["echo", "Hello"]), - Execute(argv=["echo", "Hello"]), - smpsh.ExecuteResponse, - smpsh.ShellManagementErrorV1, - smpsh.ShellManagementErrorV2, - ), - ( - smpic.ImageUploadWriteRequest(off=0, data=b"a"), - ic.ImageUploadWrite(off=0, data=b"a"), - smpic.ImageUploadWriteResponse, - smpic.ErrorV1, - smpic.ErrorV2, - ), - ( - smpfs.FileDownloadRequest(off=0, name="test.txt"), - FileDownload(off=0, name="test.txt"), - smpfs.FileDownloadResponse, - smpfs.FileSystemManagementErrorV1, - smpfs.FileSystemManagementErrorV2, - ), - ( - smpfs.FileUploadRequest(off=0, name="test.txt", data=b"a", len=100), - FileUpload(off=0, name="test.txt", data=b"a", len=100), - smpfs.FileUploadResponse, - smpfs.FileSystemManagementErrorV1, - smpfs.FileSystemManagementErrorV2, - ), - ( - smpfs.FileStatusRequest(name="test.txt"), - FileStatus(name="test.txt"), - smpfs.FileStatusResponse, - smpfs.FileSystemManagementErrorV1, - smpfs.FileSystemManagementErrorV2, - ), - ( - smpfs.FileHashChecksumRequest(name="test.txt", type="sha256", off=0, len=200), - FileHashChecksum(name="test.txt", type="sha256", off=0, len=200), - smpfs.FileHashChecksumResponse, - smpfs.FileSystemManagementErrorV1, - smpfs.FileSystemManagementErrorV2, - ), - ( - smpfs.SupportedFileHashChecksumTypesRequest(), - SupportedFileHashChecksumTypes(), - smpfs.SupportedFileHashChecksumTypesResponse, - smpfs.FileSystemManagementErrorV1, - smpfs.FileSystemManagementErrorV2, - ), - ( - smpfs.FileCloseRequest(), - FileClose(), - smpfs.FileCloseResponse, - smpfs.FileSystemManagementErrorV1, - smpfs.FileSystemManagementErrorV2, - ), - ( - smpos.BootloaderInformationReadRequest(), - BootloaderInformationRead(), - smpos.BootloaderInformationReadResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.DateTimeReadRequest(), - DateTimeRead(), - smpos.DateTimeReadResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.DateTimeWriteRequest(datetime="2040-01-01T00:00:00"), - DateTimeWrite(datetime="2040-01-01T00:00:00"), - smpos.DateTimeWriteResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.MCUMgrParametersReadRequest(), - MCUMgrParametersRead(), - smpos.MCUMgrParametersReadResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.MemoryPoolStatisticsReadRequest(), - MemoryPoolStatisticsRead(), - smpos.MemoryPoolStatisticsReadResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.OSApplicationInfoReadRequest(), - OSApplicationInfoRead(), - smpos.OSApplicationInfoReadResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpos.TaskStatisticsReadRequest(), - TaskStatisticsRead(), - smpos.TaskStatisticsReadResponse, - smpos.OSManagementErrorV1, - smpos.OSManagementErrorV2, - ), - ( - smpset.CommitSettingsRequest(), - CommitSettings(), - smpset.CommitSettingsResponse, - smpset.SettingsManagementErrorV1, - smpset.SettingsManagementErrorV2, - ), - ( - smpset.DeleteSettingRequest(name="test"), - DeleteSetting(name="test"), - smpset.DeleteSettingResponse, - smpset.SettingsManagementErrorV1, - smpset.SettingsManagementErrorV2, - ), - ( - smpset.LoadSettingsRequest(), - LoadSettings(), - smpset.LoadSettingsResponse, - smpset.SettingsManagementErrorV1, - smpset.SettingsManagementErrorV2, - ), - ( - smpset.ReadSettingRequest(name="test"), - ReadSetting(name="test"), - smpset.ReadSettingResponse, - smpset.SettingsManagementErrorV1, - smpset.SettingsManagementErrorV2, - ), - ( - smpset.SaveSettingsRequest(), - SaveSettings(), - smpset.SaveSettingsResponse, - smpset.SettingsManagementErrorV1, - smpset.SettingsManagementErrorV2, - ), - ( - smpset.WriteSettingRequest(name="test", val=b"value"), - WriteSetting(name="test", val=b"value"), - smpset.WriteSettingResponse, - smpset.SettingsManagementErrorV1, - smpset.SettingsManagementErrorV2, - ), - ( - smpstat.GroupDataRequest(name="test"), - GroupData(name="test"), - smpstat.GroupDataResponse, - smpstat.StatisticsManagementErrorV1, - smpstat.StatisticsManagementErrorV2, - ), - ( - smpstat.ListOfGroupsRequest(), - ListOfGroups(), - smpstat.ListOfGroupsResponse, - smpstat.StatisticsManagementErrorV1, - smpstat.StatisticsManagementErrorV2, - ), - ( - smpz.EraseStorageRequest(), - EraseStorage(), - smpz.EraseStorageResponse, - smpz.ZephyrManagementErrorV1, - smpz.ZephyrManagementErrorV2, - ), - ), -) -def test_requests( - test_tuple: tuple[ - smpmsg.Request, - SMPRequest[TRep, TEr1, TEr2], - type[smpmsg.Response], - type[smperr.ErrorV1], - type[smperr.ErrorV2], - ], -) -> None: - a, b, Response, ErrorV1, ErrorV2 = test_tuple - - # assert that headers match (other than sequence) - assert a.header.op == b.header.op - assert a.header.version == b.header.version - assert a.header.flags == b.header.flags - assert a.header.length == b.header.length - assert a.header.group_id == b.header.group_id - assert a.header.command_id == b.header.command_id - - # assert that the CBOR payloads match - amodel = a.model_dump(exclude_unset=True, exclude={'header'}, exclude_none=True) - bmodel = b.model_dump(exclude_unset=True, exclude={'header'}, exclude_none=True) # type: ignore - assert amodel == bmodel - assert a.BYTES[smphdr.Header.SIZE :] == b.BYTES[smphdr.Header.SIZE :] - - # assert that the response and error types are as expected - assert b._Response is Response - assert b._ErrorV1 is ErrorV1 - assert b._ErrorV2 is ErrorV2 - # assert that the response and error types are as expected - assert b._Response is Response - assert b._ErrorV1 is ErrorV1 - assert b._ErrorV2 is ErrorV2 diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index 5e927ca..a911e9d 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -9,8 +9,8 @@ from bleak import BleakClient from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice +from smp.os_management import EchoWriteResponse -from smpclient.requests.os_management import EchoWrite from smpclient.transport import SMPTransportDisconnected from smpclient.transport.ble import ( MAC_ADDRESS_PATTERN, @@ -166,20 +166,20 @@ async def test_receive() -> None: t._smp_characteristic.uuid = str(SMP_CHARACTERISTIC_UUID) t._disconnected_event.clear() # pretend t.connect() was successful - REP = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!").BYTES # type: ignore + REP = bytes(EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0)) b, _ = await asyncio.gather( t.receive(), - t._notify_callback(t._smp_characteristic, REP), + t._notify_callback(t._smp_characteristic, bytearray(REP)), ) assert b == REP # cool, now try with a fragmented response async def fragmented_notifies() -> None: - await t._notify_callback(t._smp_characteristic, REP[:10]) + await t._notify_callback(t._smp_characteristic, bytearray(REP[:10])) await asyncio.sleep(0.001) - await t._notify_callback(t._smp_characteristic, REP[10:]) + await t._notify_callback(t._smp_characteristic, bytearray(REP[10:])) b, _ = await asyncio.gather( t.receive(), diff --git a/tests/test_smp_bumble_transport.py b/tests/test_smp_bumble_transport.py index 5e4eeda..31d9692 100644 --- a/tests/test_smp_bumble_transport.py +++ b/tests/test_smp_bumble_transport.py @@ -152,12 +152,10 @@ async def test_send_single_chunk_when_smaller_than_max_write() -> None: @pytest.mark.asyncio async def test_receive_assembles_smp_message_from_notification_chunks() -> None: - from smpclient.requests.os_management import EchoWrite + from smp.os_management import EchoWriteResponse t, _ = _make_connected() - response_bytes = EchoWrite._Response.get_default()( # type: ignore[attr-defined] - sequence=0, r="hi" - ).BYTES + response_bytes = bytes(EchoWriteResponse(r="hi").to_frame(sequence=0)) async def push_chunks() -> None: await asyncio.sleep(0) # let receive() start awaiting first diff --git a/tests/test_smp_client.py b/tests/test_smp_client.py index 22666e8..0570a3e 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -1,19 +1,25 @@ """Tests for `SMPClient`.""" +from dataclasses import replace from hashlib import sha256 from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, PropertyMock, call, patch import pytest from smp import header as smphdr +from smp import message as smpmsg from smp import packet as smppacket from smp.error import MGMT_ERR from smp.error import Err as SMPErr +from smp.exceptions import SMPMismatchedGroupId from smp.file_management import ( FS_MGMT_ERR, + FileDownloadRequest, FileDownloadResponse, FileSystemManagementErrorV1, FileSystemManagementErrorV2, + FileUploadRequest, FileUploadResponse, ) from smp.image_management import ( @@ -25,17 +31,15 @@ ) from smp.os_management import ( OS_MGMT_RET_RC, + EchoWriteResponse, OSManagementErrorV1, OSManagementErrorV2, + ResetWriteRequest, ResetWriteResponse, ) -from smpclient import SMPClient +from smpclient import SMPClient, error, error_v1, error_v2, success, wrapping_sequence from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException -from smpclient.generics import error, error_v1, error_v2, success -from smpclient.requests.file_management import FileDownload, FileUpload -from smpclient.requests.image_management import ImageUploadWrite -from smpclient.requests.os_management import ResetWrite from smpclient.transport.serial import ( BufferParams, BufferSize, @@ -47,6 +51,30 @@ """The SMP serial frame's 2-byte length + 2-byte CRC16 that share the decoded buffer.""" +def error_bytes(err: smpmsg.Response, command_id: smphdr.AnyCommandId) -> bytes: + """Serialize an error response, whose header cannot be synthesized by `to_frame()`. + + `ErrorV1`/`ErrorV2` declare no `_OP` or `_COMMAND_ID` -- only the group they belong + to -- so a caller that needs their bytes builds the header itself. `SMPMockTransport` + re-stamps the sequence, so the one here is arbitrary. + """ + payload = bytes(err) + return ( + bytes( + smphdr.Header( + op=smphdr.OP.WRITE_RSP, + version=smphdr.Version.V2, + flags=smphdr.Flag(0), + length=len(payload), + group_id=err._GROUP_ID, + sequence=0, + command_id=command_id, + ) + ) + + payload + ) + + class SMPMockTransport: """Satisfies the `SMPTransport` `Protocol`.""" @@ -59,6 +87,8 @@ def __init__(self) -> None: self.initialize = AsyncMock() self._mtu = 0 self._max_unencoded_size = 0 + self.sequence_offset = 0 + """Added to the echoed sequence; non-zero fakes a server answering out of order.""" @property def mtu(self) -> int: @@ -69,8 +99,29 @@ def max_unencoded_size(self) -> int: return self._max_unencoded_size async def send_and_receive(self, data: bytes) -> bytes: + """Answer with `receive()`'s frame, re-stamped with the sequence a server would echo. + + `SMPClient.request` draws the sequence from smp's counter when it frames the + request, so a test cannot know it in advance. + """ await self.send(data) - return await self.receive() + response: bytes = await self.receive() + sequence: int = smphdr.Header.loads(data[: smphdr.Header.SIZE]).sequence + return ( + bytes( + replace( + smphdr.Header.loads(response[: smphdr.Header.SIZE]), + sequence=(sequence + self.sequence_offset) % 0x100, + ) + ) + + response[smphdr.Header.SIZE :] + ) + + +def sent_frame(m: SMPMockTransport) -> Any: + """The frame handed to the most recent `send`.""" + assert m.send.await_args is not None + return m.send.await_args.args[0] def test_constructor() -> None: @@ -96,10 +147,10 @@ async def test_request() -> None: m = SMPMockTransport() s = SMPClient(m, "address") - req = ResetWrite() - m.receive.return_value = ResetWriteResponse(sequence=req.header.sequence).BYTES + req = ResetWriteRequest() + m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) rep = await s.request(req) - m.send.assert_has_awaits([call(req.BYTES)]) + assert ResetWriteRequest.loads(sent_frame(m)).data == req m.receive.assert_awaited() assert type(rep) is req._Response assert success(rep) is True @@ -108,28 +159,19 @@ async def test_request() -> None: assert error_v2(rep) is False # test that a bad sequence raises `SMPBadSequence` - req = ResetWrite() - m.receive.return_value = ResetWriteResponse(sequence=req.header.sequence + 1).BYTES + m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) + m.sequence_offset = 1 with pytest.raises(SMPBadSequence): await s.request(req) + m.sequence_offset = 0 # test that a genric MGMT_ERR error response is parsed - req = ResetWrite() - m.receive.return_value = OSManagementErrorV1( - header=smphdr.Header( - op=smphdr.OP.WRITE_RSP, - version=smphdr.Version.V1, - flags=smphdr.Flag(0), - length=5, - group_id=req.header.group_id, - sequence=req.header.sequence, - command_id=req.header.command_id, - ), - rc=MGMT_ERR.ENOTSUP, - ).BYTES + m.receive.return_value = error_bytes( + OSManagementErrorV1(rc=MGMT_ERR.ENOTSUP), smphdr.CommandId.OSManagement.RESET + ) rep = await s.request(req) - m.send.assert_has_awaits([call(req.BYTES)]) + assert ResetWriteRequest.loads(sent_frame(m)).data == req m.receive.assert_awaited() assert success(rep) is False assert error_v2(rep) is False @@ -141,24 +183,17 @@ async def test_request() -> None: raise AssertionError(f"Unexpected response type: {type(rep)}") # test that an OS_MGMT_RET_RC error response is parsed - req = ResetWrite() - # _header = ResetWriteResponse(sequence=req.header.sequence).header - header = smphdr.Header( - op=smphdr.OP.WRITE_RSP, - version=smphdr.Version.V2, - flags=smphdr.Flag(0), - length=17, - group_id=smphdr.GroupId.OS_MANAGEMENT, - sequence=req.sequence, - command_id=smphdr.CommandId.OSManagement.RESET, + m.receive.return_value = error_bytes( + OSManagementErrorV2( + err=SMPErr[OS_MGMT_RET_RC]( + rc=OS_MGMT_RET_RC.UNKNOWN, group=smphdr.GroupId.OS_MANAGEMENT + ) + ), + smphdr.CommandId.OSManagement.RESET, ) - m.receive.return_value = OSManagementErrorV2( - header=header, - err=SMPErr[OS_MGMT_RET_RC](rc=OS_MGMT_RET_RC.UNKNOWN, group=smphdr.GroupId.OS_MANAGEMENT), - ).BYTES rep = await s.request(req) - m.send.assert_has_awaits([call(req.BYTES)]) + assert ResetWriteRequest.loads(sent_frame(m)).data == req m.receive.assert_awaited() assert success(rep) is False assert error(rep) is True @@ -176,9 +211,10 @@ async def test_request_unparseable_frame() -> None: m = SMPMockTransport() s = SMPClient(m, "address") - req = ResetWrite() - # A frame from a different group/command can be parsed as none of `ResetWrite`'s types. - m.receive.return_value = ImageUploadWriteResponse(sequence=req.header.sequence, off=0).BYTES + req = ResetWriteRequest() + # Same group, so the frame reaches the decoders -- but `r` is a field of none of + # `ResetWriteRequest`'s three response types, so every one of them rejects it. + m.receive.return_value = bytes(EchoWriteResponse(r="not a reset response").to_frame(sequence=0)) with pytest.raises(SMPValidationException) as exc_info: await s.request(req) @@ -190,6 +226,72 @@ async def test_request_unparseable_frame() -> None: assert req._ErrorV2.__name__ in exc_info.value.details +def test_wrapping_sequence() -> None: + """The default sequence space covers the header's 8 bit field and wraps.""" + sequence = wrapping_sequence() + + assert [next(sequence) for _ in range(0x100)] == list(range(0x100)) + assert next(sequence) == 0 + + +@pytest.mark.asyncio +async def test_injected_sequence() -> None: + """The sequence space is injectable, so a test can pin what goes on the wire.""" + m = SMPMockTransport() + s = SMPClient(m, "address", sequence=iter((7, 9))) + m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) + + for expected in (7, 9): + await s.request(ResetWriteRequest()) + assert smphdr.Header.loads(sent_frame(m)[: smphdr.Header.SIZE]).sequence == expected + + +@pytest.mark.asyncio +async def test_request_mismatched_group_propagates() -> None: + """A frame from the wrong group is a transport error, not an unparseable response. + + It fails all three candidate types identically, so `SMPMismatchedGroupId` propagates + instead of being collected as one more parse failure. + """ + m = SMPMockTransport() + s = SMPClient(m, "address") + + m.receive.return_value = bytes(ImageUploadWriteResponse(off=0).to_frame(sequence=0)) + + with pytest.raises(SMPMismatchedGroupId): + await s.request(ResetWriteRequest()) + + +@pytest.mark.asyncio +async def test_request_truncated_payload_is_diagnosed() -> None: + """A payload that is not decodable CBOR still raises the diagnostic exception. + + msgspec reports this as a bare `DecodeError` rather than a `ValidationError`, so the + decode chain catches the wider type. + """ + m = SMPMockTransport() + s = SMPClient(m, "address") + + truncated = b"\xbf\x61\x72" # an indefinite-length map that simply stops + m.receive.return_value = ( + bytes( + smphdr.Header( + op=smphdr.OP.WRITE_RSP, + version=smphdr.Version.V2, + flags=smphdr.Flag(0), + length=len(truncated), + group_id=smphdr.GroupId.OS_MANAGEMENT, + sequence=0, + command_id=smphdr.CommandId.OSManagement.RESET, + ) + ) + + truncated + ) + + with pytest.raises(SMPValidationException): + await s.request(ResetWriteRequest()) + + @pytest.mark.asyncio async def test_upload() -> None: m = SMPMockTransport() @@ -203,32 +305,13 @@ async def test_upload() -> None: chunk_size = 415 # max chunk given MTU image = bytes([i % 255 for i in range(4097)]) - req = ImageUploadWrite( - off=0, - data=image[:chunk_size], - image=0, - len=len(image), - sha=sha256(image).digest(), - upgrade=False, - ) - u = s.upload(image) - h = req.header - s.request.return_value = ImageUploadWrite._Response.get_default()(off=415) # type: ignore + s.request.return_value = ImageUploadWriteResponse(off=415) # type: ignore offset = await anext(u) assert offset == 415 s.request.assert_awaited_once_with( - ImageUploadWrite( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 2) % 0xFF, - command_id=h.command_id, - ), + ImageUploadWriteRequest( off=0, data=image[:chunk_size], image=0, @@ -239,20 +322,11 @@ async def test_upload() -> None: timeout_s=40.000, ) - s.request.return_value = ImageUploadWrite._Response.get_default()(off=415 + 474) # type: ignore + s.request.return_value = ImageUploadWriteResponse(off=415 + 474) # type: ignore offset = await anext(u) assert offset == 415 + 474 s.request.assert_awaited_with( - ImageUploadWrite( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 4) % 0xFF, - command_id=h.command_id, - ), + ImageUploadWriteRequest( off=415, data=image[415 : 415 + 474], ), @@ -261,35 +335,16 @@ async def test_upload() -> None: # assert that upload() raises SMPUploadError s.request.return_value = ImageManagementErrorV1( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=5, - group_id=req.header.group_id, - sequence=(req.header.sequence + 6) % 0xFF, - command_id=req.header.command_id, - ), rc=MGMT_ERR.ECORRUPT, ) with pytest.raises(SMPUploadError) as e: _ = await anext(u) assert e.value.args[0].rc == MGMT_ERR.ECORRUPT u = s.upload(image) - h = req.header s.request.return_value = ImageManagementErrorV2( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=17, - group_id=req.header.group_id, - sequence=(req.header.sequence + 7) % 0xFF, - command_id=req.header.command_id, - ), err=SMPErr( # type: ignore rc=IMG_MGMT_ERR.FLASH_WRITE_FAILED, group=smphdr.GroupId.IMAGE_MANAGEMENT - ).model_dump(), + ), ) with pytest.raises(SMPUploadError) as e: _ = await anext(u) @@ -318,10 +373,10 @@ async def test_upload_hello_world_bin( accumulated_image = bytearray([]) async def mock_request( - request: ImageUploadWrite, timeout_s: float = 120.000 + request: ImageUploadWriteRequest, timeout_s: float = 120.000 ) -> ImageUploadWriteResponse: accumulated_image.extend(request.data) - return ImageUploadWrite._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa + return ImageUploadWriteResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -370,12 +425,12 @@ def mock_write(data: bytes) -> int: type(s._transport._conn).out_waiting = 0 # type: ignore async def mock_request( - request: ImageUploadWrite, timeout_s: float = 120.000 + request: ImageUploadWriteRequest, timeout_s: float = 120.000 ) -> ImageUploadWriteResponse: # call the real send method (with write mocked) but don't bother with receive # this does provide coverage for the MTU-limited encoding done in the send method - await s._transport.send(request.BYTES) - return ImageUploadWrite._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa + await s._transport.send(bytes(request.to_frame(sequence=0))) + return ImageUploadWriteResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -395,7 +450,7 @@ async def mock_request( try: decoder.send(packet) except StopIteration as e: - reconstructed_request = ImageUploadWriteRequest.loads(e.value) + reconstructed_request = ImageUploadWriteRequest.loads(e.value).data reconstructed_image.extend(reconstructed_request.data) decoder = smppacket.decode() @@ -428,12 +483,12 @@ def mock_write(data: bytes) -> int: s._transport._conn.write = mock_write # type: ignore async def mock_request( - request: ImageUploadWrite, timeout_s: float = 120.000 + request: ImageUploadWriteRequest, timeout_s: float = 120.000 ) -> ImageUploadWriteResponse: # call the real send method (with write mocked) but don't bother with receive # this provides coverage for the MTU-limited chunking done by SMPClient.upload - await s._transport.send(request.BYTES) - return ImageUploadWrite._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa + await s._transport.send(bytes(request.to_frame(sequence=0))) + return ImageUploadWriteResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -446,7 +501,7 @@ async def mock_request( # Each captured write is one complete SMP message [header][payload], no decoding needed. reconstructed_image = bytearray([]) for packet in packets: - reconstructed_image.extend(ImageUploadWriteRequest.loads(packet).data) + reconstructed_image.extend(ImageUploadWriteRequest.loads(packet).data.data) assert reconstructed_image == image @@ -464,25 +519,13 @@ async def test_upload_file() -> None: chunk_size = 455 # max chunk given MTU data = bytes([i % 255 for i in range(4097)]) - req = FileUpload(off=0, data=data[:chunk_size], len=len(data), name="test.txt") - u = s.upload_file(data, file_path="test.txt") - h = req.header - s.request.return_value = FileUpload._Response.get_default()(off=455) # type: ignore + s.request.return_value = FileUploadResponse(off=455) # type: ignore offset = await anext(u) assert offset == 455 s.request.assert_awaited_once_with( - FileUpload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 2) % 0xFF, - command_id=h.command_id, - ), + FileUploadRequest( off=0, data=data[:chunk_size], len=len(data), @@ -491,20 +534,11 @@ async def test_upload_file() -> None: timeout_s=2.500, ) - s.request.return_value = FileUpload._Response.get_default()(off=455 + 460) # type: ignore + s.request.return_value = FileUploadResponse(off=455 + 460) # type: ignore offset = await anext(u) assert offset == 455 + 460 s.request.assert_awaited_with( - FileUpload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 4) % 0xFF, - command_id=h.command_id, - ), + FileUploadRequest( off=455, data=data[455 : 455 + 460], name="test.txt", @@ -514,15 +548,6 @@ async def test_upload_file() -> None: # assert that upload() raises SMPUploadError s.request.return_value = FileSystemManagementErrorV1( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=5, - group_id=req.header.group_id, - sequence=(req.header.sequence + 5) % 0xFF, - command_id=req.header.command_id, - ), rc=MGMT_ERR.EACCESSDENIED, ) @@ -530,20 +555,10 @@ async def test_upload_file() -> None: _ = await anext(u) assert e.value.args[0].rc == MGMT_ERR.EACCESSDENIED u = s.upload_file(data, file_path="test.txt") - h = req.header s.request.return_value = FileSystemManagementErrorV2( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=17, - group_id=req.header.group_id, - sequence=(req.header.sequence + 6) % 0xFF, - command_id=req.header.command_id, - ), err=SMPErr( # type: ignore rc=FS_MGMT_ERR.FILE_WRITE_FAILED, group=smphdr.GroupId.FILE_MANAGEMENT - ).model_dump(), + ), ) with pytest.raises(SMPUploadError) as e: _ = await anext(u) @@ -570,9 +585,11 @@ async def test_file_upload_test_txt( accumulated_data = bytearray([]) - async def mock_request(request: FileUpload, timeout_s: float = 120.000) -> FileUploadResponse: + async def mock_request( + request: FileUploadRequest, timeout_s: float = 120.000 + ) -> FileUploadResponse: accumulated_data.extend(request.data) - return FileUpload._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa + return FileUploadResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -602,9 +619,11 @@ async def test_file_upload_test_255_bytes_file( accumulated_data = bytearray([]) - async def mock_request(request: FileUpload, timeout_s: float = 120.000) -> FileUploadResponse: + async def mock_request( + request: FileUploadRequest, timeout_s: float = 120.000 + ) -> FileUploadResponse: accumulated_data.extend(request.data) - return FileUpload._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa + return FileUploadResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -651,12 +670,12 @@ def mock_write(data: bytes) -> int: type(s._transport._conn).out_waiting = 0 # type: ignore async def mock_request( - request: ImageUploadWrite, timeout_s: float = 120.000 + request: ImageUploadWriteRequest, timeout_s: float = 120.000 ) -> ImageUploadWriteResponse: # call the real send method (with write mocked) but don't bother with receive # this does provide coverage for the MTU-limited encoding done in the send method - await s._transport.send(request.BYTES) - return ImageUploadWrite._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa + await s._transport.send(bytes(request.to_frame(sequence=0))) + return ImageUploadWriteResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -676,7 +695,7 @@ async def mock_request( try: decoder.send(packet) except StopIteration as e: - reconstructed_request = ImageUploadWriteRequest.loads(e.value) + reconstructed_request = ImageUploadWriteRequest.loads(e.value).data reconstructed_file.extend(reconstructed_request.data) decoder = smppacket.decode() @@ -708,150 +727,66 @@ async def test_download_file() -> None: FileDownloadResponse(off=3648, data=data[3648:4097]), ] - req = FileDownload(off=3648, name="test.txt") - h = req.header - file_data = await s.download_file(file_path="test.txt") calls = [ call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length - 2, # Decrease size ass offset of 0 uses 2 less bytes - group_id=h.group_id, - sequence=(h.sequence + 1) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=0, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 2) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=456, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 3) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=912, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 4) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=1368, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 5) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=1824, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 6) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=2280, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 7) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=2736, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 8) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=3192, name="test.txt", ), timeout_s=2.500, ), call( - FileDownload( - header=smphdr.Header( - op=h.op, - version=h.version, - flags=h.flags, - length=h.length, - group_id=h.group_id, - sequence=(h.sequence + 9) % 0xFF, - command_id=h.command_id, - ), + FileDownloadRequest( off=3648, name="test.txt", ), @@ -873,25 +808,10 @@ async def test_download_file_error_first() -> None: s.request = AsyncMock() # type: ignore - req = FileDownload( - off=3648, - name="test.txt", - sequence=0, - ) - s.request.return_value = FileSystemManagementErrorV2( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=17, - group_id=req.header.group_id, - sequence=req.header.sequence + 6, - command_id=req.header.command_id, - ), err=SMPErr( # type: ignore rc=FS_MGMT_ERR.FILE_WRITE_FAILED, group=smphdr.GroupId.FILE_MANAGEMENT - ).model_dump(), + ), ) with pytest.raises(SMPUploadError) as e: @@ -906,23 +826,9 @@ async def test_download_file_no_len_first() -> None: s.request = AsyncMock() # type: ignore - req = FileDownload( - off=3648, - name="test.txt", - sequence=0, - ) data = bytes([i % 255 for i in range(4097)]) s.request.return_value = FileDownloadResponse( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=472, - group_id=req.header.group_id, - sequence=req.header.sequence + 1, - command_id=req.header.command_id, - ), off=456, data=data[:456], ) @@ -939,41 +845,18 @@ async def test_download_file_error_not_first() -> None: s.request = AsyncMock() # type: ignore - req = FileDownload( - off=3648, - name="test.txt", - sequence=0, - ) data = bytes([i % 255 for i in range(4097)]) s.request.side_effect = [ FileDownloadResponse( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=479, - group_id=req.header.group_id, - sequence=req.header.sequence + 1, - command_id=req.header.command_id, - ), off=456, data=data[:456], len=len(data), ), FileSystemManagementErrorV2( - header=smphdr.Header( - op=req.header.op, - version=req.header.version, - flags=req.header.flags, - length=17, - group_id=req.header.group_id, - sequence=req.header.sequence + 2, - command_id=req.header.command_id, - ), err=SMPErr( # type: ignore rc=FS_MGMT_ERR.FILE_WRITE_FAILED, group=smphdr.GroupId.FILE_MANAGEMENT - ).model_dump(), + ), ), ] with pytest.raises(SMPUploadError) as e: @@ -992,7 +875,7 @@ def test_maximize_upload_packet_fills_decoded_buffer( Filling the decoded reassembly buffer (`buf_size - 4`) is the whole point of the maximizer: the resulting SMP message base64-encodes to a frame ~1.37x `buf_size` on the wire -- larger than the buffer, which the server decodes incrementally as the - lines arrive. The unified generic handles both `ImageUploadWrite` and `FileUpload`. + lines arrive. The unified generic handles both `ImageUploadWriteRequest` and `FileUploadRequest`. """ client = SMPClient( SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=buf_size)), @@ -1003,17 +886,21 @@ def test_maximize_upload_packet_fills_decoded_buffer( image = b"\xa5" * (4 * buf_size) # plenty of source so the packet is never a short final image_packet = client._maximize_upload_packet( - ImageUploadWrite(off=0, data=b"", image=0, len=len(image), sha=sha256(image).digest()), + ImageUploadWriteRequest( + off=0, data=b"", image=0, len=len(image), sha=sha256(image).digest() + ), image, ) file_packet = client._maximize_upload_packet( - FileUpload(name="/lfs1/firmware.bin", off=0, data=b"", len=len(image)), image + FileUploadRequest(name="/lfs1/firmware.bin", off=0, data=b"", len=len(image)), image ) for maximized in (image_packet, file_packet): + frame = bytes(maximized.to_frame(sequence=0)) + # the maximizer fills the decoded reassembly buffer exactly - assert len(maximized.BYTES) == max_unencoded_size + assert len(frame) == max_unencoded_size # ... so the encoded frame on the wire is ~1.37x buf_size -- bigger than the buffer - on_wire = b"".join(smppacket.encode(maximized.BYTES, line_length=128)) + on_wire = b"".join(smppacket.encode(frame, line_length=128)) assert len(on_wire) == encoded_frame_size assert len(on_wire) > buf_size diff --git a/tests/test_smp_serial_raw_transport.py b/tests/test_smp_serial_raw_transport.py index 83dbcf5..5703efd 100644 --- a/tests/test_smp_serial_raw_transport.py +++ b/tests/test_smp_serial_raw_transport.py @@ -10,10 +10,10 @@ import pytest from serial import SerialException from smp import header as smphdr +from smp.os_management import EchoWriteRequest, EchoWriteResponse from smp.packet import CRC16_STRUCT, crc16_func from smpclient.exceptions import SMPClientException -from smpclient.requests.os_management import EchoWrite from smpclient.transport import SMPTransportDisconnected from smpclient.transport.serial import Cobs, SMPSerialRawTransport from smpclient.transport.serial.framing.cobs import cobs_encode @@ -71,11 +71,11 @@ async def test_send() -> None: p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore - r = EchoWrite(d="Hello pytest!") - await t.send(r.BYTES) + r = EchoWriteRequest(d="Hello pytest!").to_frame(sequence=0) + await t.send(bytes(r)) # Raw transport writes the bytes verbatim - no encoding. - t._conn.write.assert_called_once_with(r.BYTES) + t._conn.write.assert_called_once_with(bytes(r)) p.assert_called_once_with() @@ -86,7 +86,7 @@ async def test_send_waits_for_tx_drain() -> None: p = PropertyMock(side_effect=(1, 0)) type(t._conn).out_waiting = p # type: ignore - await t.send(EchoWrite(d="x").BYTES) + await t.send(bytes(EchoWriteRequest(d="x").to_frame(sequence=0))) assert p.call_count == 2 @@ -103,7 +103,7 @@ async def test_send_disconnected_raises() -> None: t._conn.write = MagicMock(side_effect=SerialException("disconnected")) # type: ignore with pytest.raises(SMPTransportDisconnected): - await t.send(EchoWrite(d="x").BYTES) + await t.send(bytes(EchoWriteRequest(d="x").to_frame(sequence=0))) @pytest.mark.asyncio @@ -111,11 +111,11 @@ async def test_receive_single_packet() -> None: t = SMPSerialRawTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore - t._conn.read_all = MagicMock(side_effect=[m.BYTES]) # type: ignore + m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) + t._conn.read_all = MagicMock(side_effect=[bytes(m)]) # type: ignore received = await t.receive() - assert received == m.BYTES + assert received == bytes(m) await t.disconnect() @@ -125,17 +125,17 @@ async def test_receive_fragmented() -> None: t = SMPSerialRawTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore + m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) fragments = [ - m.BYTES[:3], # less than a header - m.BYTES[3:8], # completes the header but no payload yet - m.BYTES[8:10], - m.BYTES[10:], # rest of payload + bytes(m)[:3], # less than a header + bytes(m)[3:8], # completes the header but no payload yet + bytes(m)[8:10], + bytes(m)[10:], # rest of payload ] t._conn.read_all = MagicMock(side_effect=fragments) # type: ignore received = await t.receive() - assert received == m.BYTES + assert received == bytes(m) await t.disconnect() @@ -145,13 +145,13 @@ async def test_receive_byte_at_a_time() -> None: t = SMPSerialRawTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="Hi") # type: ignore + m = EchoWriteResponse(r="Hi").to_frame(sequence=0) t._conn.read_all = MagicMock( # type: ignore - side_effect=[bytes([b]) for b in m.BYTES] + side_effect=[bytes([b]) for b in bytes(m)] ) received = await t.receive() - assert received == m.BYTES + assert received == bytes(m) await t.disconnect() @@ -161,16 +161,16 @@ async def test_receive_consecutive_messages() -> None: t = SMPSerialRawTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m1 = EchoWrite._Response.get_default()(sequence=0, r="SMP Message 1") # type: ignore - m2 = EchoWrite._Response.get_default()(sequence=1, r="SMP Message 2") # type: ignore - m3 = EchoWrite._Response.get_default()(sequence=2, r="SMP Message 3") # type: ignore + m1 = EchoWriteResponse(r="SMP Message 1").to_frame(sequence=0) + m2 = EchoWriteResponse(r="SMP Message 2").to_frame(sequence=1) + m3 = EchoWriteResponse(r="SMP Message 3").to_frame(sequence=2) # Each receive() reads one full message, just like a normal request/response loop. - t._conn.read_all = MagicMock(side_effect=[m1.BYTES, m2.BYTES, m3.BYTES]) # type: ignore + t._conn.read_all = MagicMock(side_effect=[bytes(m1), bytes(m2), bytes(m3)]) # type: ignore - assert await t.receive() == m1.BYTES - assert await t.receive() == m2.BYTES - assert await t.receive() == m3.BYTES + assert await t.receive() == bytes(m1) + assert await t.receive() == bytes(m2) + assert await t.receive() == bytes(m3) await t.disconnect() @@ -184,8 +184,8 @@ async def test_receive_overrun_raises() -> None: t = SMPSerialRawTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="Hello!") # type: ignore - t._conn.read_all = MagicMock(side_effect=[m.BYTES + b"\x00\x01\x02"]) # type: ignore + m = EchoWriteResponse(r="Hello!").to_frame(sequence=0) + t._conn.read_all = MagicMock(side_effect=[bytes(m) + b"\x00\x01\x02"]) # type: ignore with pytest.raises(SMPClientException): await t.receive() @@ -198,11 +198,11 @@ async def test_receive_polls_when_nothing_available() -> None: t = SMPSerialRawTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="ok") # type: ignore - t._conn.read_all = MagicMock(side_effect=[b"", b"", m.BYTES]) # type: ignore + m = EchoWriteResponse(r="ok").to_frame(sequence=0) + t._conn.read_all = MagicMock(side_effect=[b"", b"", bytes(m)]) # type: ignore received = await t.receive() - assert received == m.BYTES + assert received == bytes(m) assert t._conn.read_all.call_count >= 3 await t.disconnect() @@ -263,7 +263,7 @@ async def test_send_with_cobs_framing_encodes() -> None: p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore - msg = EchoWrite(d="Hello pytest!").BYTES + msg = bytes(EchoWriteRequest(d="Hello pytest!").to_frame(sequence=0)) await t.send(msg) expected = cobs_encode(msg + CRC16_STRUCT.pack(crc16_func(msg))) + b"\x00" @@ -275,11 +275,11 @@ async def test_receive_with_cobs_framing_decodes() -> None: t = SMPSerialRawTransport(framing=Cobs()) await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore - (wire,) = Cobs().encode(m.BYTES) + m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) + (wire,) = Cobs().encode(bytes(m)) t._conn.read_all = MagicMock(side_effect=[wire]) # type: ignore - assert await t.receive() == m.BYTES + assert await t.receive() == bytes(m) await t.disconnect() @@ -289,11 +289,11 @@ async def test_receive_with_cobs_framing_fragmented() -> None: t = SMPSerialRawTransport(framing=Cobs()) await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="fragment me across reads") # type: ignore - (wire,) = Cobs().encode(m.BYTES) + m = EchoWriteResponse(r="fragment me across reads").to_frame(sequence=0) + (wire,) = Cobs().encode(bytes(m)) t._conn.read_all = MagicMock(side_effect=[wire[:5], b"", wire[5:]]) # type: ignore - assert await t.receive() == m.BYTES + assert await t.receive() == bytes(m) await t.disconnect() @@ -307,14 +307,14 @@ async def test_receive_two_cobs_frames_in_one_read() -> None: t = SMPSerialRawTransport(framing=Cobs()) await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m1 = EchoWrite._Response.get_default()(sequence=0, r="first") # type: ignore - m2 = EchoWrite._Response.get_default()(sequence=1, r="second") # type: ignore - (w1,) = Cobs().encode(m1.BYTES) - (w2,) = Cobs().encode(m2.BYTES) + m1 = EchoWriteResponse(r="first").to_frame(sequence=0) + m2 = EchoWriteResponse(r="second").to_frame(sequence=1) + (w1,) = Cobs().encode(bytes(m1)) + (w2,) = Cobs().encode(bytes(m2)) t._conn.read_all = MagicMock(side_effect=[w1 + w2]) # type: ignore - assert await t.receive() == m1.BYTES - assert await t.receive() == m2.BYTES # from leftover; read_all not consulted again + assert await t.receive() == bytes(m1) + assert await t.receive() == bytes(m2) # from leftover; read_all not consulted again assert t._conn.read_all.call_count == 1 await t.disconnect() @@ -330,15 +330,16 @@ async def test_receive_cobs_framing_resyncs_past_corrupt_frame() -> None: t = SMPSerialRawTransport(framing=Cobs()) await t.connect("/dev/ttyUSB0", timeout_s=1.0) - dropped = EchoWrite._Response.get_default()(sequence=0, r="dropped") # type: ignore - recovered = EchoWrite._Response.get_default()(sequence=1, r="recovered") # type: ignore + dropped = EchoWriteResponse(r="dropped").to_frame(sequence=0) + recovered = EchoWriteResponse(r="recovered").to_frame(sequence=1) corrupt = ( - cobs_encode(dropped.BYTES + CRC16_STRUCT.pack(crc16_func(dropped.BYTES) ^ 0xFFFF)) + b"\x00" + cobs_encode(bytes(dropped) + CRC16_STRUCT.pack(crc16_func(bytes(dropped)) ^ 0xFFFF)) + + b"\x00" ) - (good,) = Cobs().encode(recovered.BYTES) + (good,) = Cobs().encode(bytes(recovered)) t._conn.read_all = MagicMock(side_effect=[corrupt + good]) # type: ignore - assert await t.receive() == recovered.BYTES + assert await t.receive() == bytes(recovered) await t.disconnect() @@ -353,8 +354,8 @@ async def test_receive_framed_yields_so_an_outer_timeout_can_fire() -> None: t = SMPSerialRawTransport(framing=Cobs()) await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m = EchoWrite._Response.get_default()(sequence=0, r="never valid") # type: ignore - corrupt = cobs_encode(m.BYTES + CRC16_STRUCT.pack(crc16_func(m.BYTES) ^ 0xFFFF)) + b"\x00" + m = EchoWriteResponse(r="never valid").to_frame(sequence=0) + corrupt = cobs_encode(bytes(m) + CRC16_STRUCT.pack(crc16_func(bytes(m)) ^ 0xFFFF)) + b"\x00" t._conn.read_all = MagicMock(return_value=corrupt) # type: ignore # endless, never valid with pytest.raises(asyncio.TimeoutError): diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index 077aa2e..b12ce9b 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -12,8 +12,8 @@ import pytest from serial import SerialException from smp import packet as smppacket +from smp.os_management import EchoWriteRequest, EchoWriteResponse -from smpclient.requests.os_management import EchoWrite from smpclient.transport import SMPTransportDisconnected from smpclient.transport.serial import ( Auto, @@ -83,8 +83,8 @@ async def test_send() -> None: p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore - r = EchoWrite(d="Hello pytest!") - await t.send(r.BYTES) + r = EchoWriteRequest(d="Hello pytest!").to_frame(sequence=0) + await t.send(bytes(r)) t._conn.write.assert_called_once() p.assert_called_once_with() @@ -92,7 +92,7 @@ async def test_send() -> None: p = PropertyMock(side_effect=(1, 0)) type(t._conn).out_waiting = p # type: ignore - await t.send(r.BYTES) + await t.send(bytes(r)) t._conn.write.assert_called_once() assert p.call_count == 2 # called twice since out buffer was not drained on first call @@ -100,21 +100,21 @@ async def test_send() -> None: @pytest.mark.asyncio async def test_receive() -> None: t = SMPSerialTransport() - m = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore - p = [p for p in smppacket.encode(m.BYTES, t.max_unencoded_size)] + m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) + p = [p for p in smppacket.encode(bytes(m), t.max_unencoded_size)] t._read_one_smp_packet = AsyncMock(side_effect=p) # type: ignore b = await t.receive() t._read_one_smp_packet.assert_awaited_once_with() - assert b == m.BYTES + assert b == bytes(m) - p = [p for p in smppacket.encode(m.BYTES, 8)] # test packet fragmentation + p = [p for p in smppacket.encode(bytes(m), 8)] # test packet fragmentation t._read_one_smp_packet = AsyncMock(side_effect=p) # type: ignore b = await t.receive() t._read_one_smp_packet.assert_awaited() - assert b == m.BYTES + assert b == bytes(m) @pytest.mark.asyncio @@ -122,10 +122,10 @@ async def test_read_one_smp_packet() -> None: t = SMPSerialTransport() await t.connect("COM2", timeout_s=1.0) - m1 = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore - m2 = EchoWrite._Response.get_default()(sequence=1, r="Hello computer!") # type: ignore - p1 = [p for p in smppacket.encode(m1.BYTES, 8)] - p2 = [p for p in smppacket.encode(m2.BYTES, 8)] + m1 = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) + m2 = EchoWriteResponse(r="Hello computer!").to_frame(sequence=1) + p1 = [p for p in smppacket.encode(bytes(m1), 8)] + p2 = [p for p in smppacket.encode(bytes(m2), 8)] packets = p1 + p2 t._conn.read_all = MagicMock(side_effect=packets) # type: ignore @@ -133,8 +133,8 @@ async def test_read_one_smp_packet() -> None: assert p == await t._read_one_smp_packet() # do again, but manually fragment the buffers - packets = [p for p in smppacket.encode(m1.BYTES, 512)] + [ - p for p in smppacket.encode(m2.BYTES, 512) + packets = [p for p in smppacket.encode(bytes(m1), 512)] + [ + p for p in smppacket.encode(bytes(m2), 512) ] assert len(packets) == 2 buffers = [ @@ -218,19 +218,19 @@ async def test_only_smp_data_no_serial() -> None: t = SMPSerialTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m1 = EchoWrite._Response.get_default()(sequence=0, r="SMP Message 1") # type: ignore - m2 = EchoWrite._Response.get_default()(sequence=1, r="SMP Message 2") # type: ignore - m3 = EchoWrite._Response.get_default()(sequence=2, r="SMP Message 3") # type: ignore + m1 = EchoWriteResponse(r="SMP Message 1").to_frame(sequence=0) + m2 = EchoWriteResponse(r="SMP Message 2").to_frame(sequence=1) + m3 = EchoWriteResponse(r="SMP Message 3").to_frame(sequence=2) packets = ( - list(smppacket.encode(m1.BYTES, 512)) - + list(smppacket.encode(m2.BYTES, 512)) - + list(smppacket.encode(m3.BYTES, 512)) + list(smppacket.encode(bytes(m1), 512)) + + list(smppacket.encode(bytes(m2), 512)) + + list(smppacket.encode(bytes(m3), 512)) ) t._conn.read_all = MagicMock(side_effect=packets) # type: ignore - for expected_msg in [m1.BYTES, m2.BYTES, m3.BYTES]: + for expected_msg in [bytes(m1), bytes(m2), bytes(m3)]: received = await t.receive() assert received == expected_msg @@ -242,11 +242,11 @@ async def test_serial_and_smp_data() -> None: t = SMPSerialTransport() await t.connect("/dev/ttyUSB0", timeout_s=1.0) - m1 = EchoWrite._Response.get_default()(sequence=0, r="SMP1") # type: ignore - m2 = EchoWrite._Response.get_default()(sequence=1, r="SMP2") # type: ignore + m1 = EchoWriteResponse(r="SMP1").to_frame(sequence=0) + m2 = EchoWriteResponse(r="SMP2").to_frame(sequence=1) - p1 = next(smppacket.encode(m1.BYTES, 512)) - p2 = next(smppacket.encode(m2.BYTES, 512)) + p1 = next(smppacket.encode(bytes(m1), 512)) + p2 = next(smppacket.encode(bytes(m2), 512)) t._conn.read_all = MagicMock( # type: ignore side_effect=[ @@ -261,10 +261,10 @@ async def test_serial_and_smp_data() -> None: # first in a row must work: received1 = await t.receive() - assert received1 == m1.BYTES + assert received1 == bytes(m1) received2 = await t.receive() - assert received2 == m2.BYTES + assert received2 == bytes(m2) data = await t.read_serial(delimiter=b"\n") assert data == b"Start" diff --git a/tests/test_smp_udp_transport.py b/tests/test_smp_udp_transport.py index 6096b59..5d74c9b 100644 --- a/tests/test_smp_udp_transport.py +++ b/tests/test_smp_udp_transport.py @@ -5,9 +5,9 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest +from smp.os_management import EchoWriteResponse from smpclient.exceptions import SMPClientException -from smpclient.requests.os_management import EchoWrite from smpclient.transport._udp_client import Addr, UDPClient from smpclient.transport.udp import IPV4_UDP_OVERHEAD, IPV6_UDP_OVERHEAD, SMPUDPTransport @@ -82,7 +82,7 @@ async def test_receive(_: MagicMock) -> None: t = SMPUDPTransport() t._client.receive = AsyncMock() # type: ignore - message = bytes(EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!")) # type: ignore # noqa + message = bytes(EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0)) # type: ignore # noqa # no fragmentation t._client.receive.return_value = message diff --git a/uv.lock b/uv.lock index 1d2ffea..aacf0b8 100644 --- a/uv.lock +++ b/uv.lock @@ -4,15 +4,15 @@ requires-python = ">=3.10, <4" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'android'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.11' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.14' and sys_platform == 'android'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'android'", - "python_full_version < '3.11' and sys_platform == 'android'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11' and sys_platform == 'android'", "python_full_version < '3.11' and sys_platform == 'emscripten'", ] @@ -30,14 +30,14 @@ name = "aiohttp" version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs", marker = "sys_platform != 'emscripten'" }, - { name = "aiosignal", marker = "sys_platform != 'emscripten'" }, - { name = "async-timeout", marker = "python_full_version < '3.11' and sys_platform != 'emscripten'" }, - { name = "attrs", marker = "sys_platform != 'emscripten'" }, - { name = "frozenlist", marker = "sys_platform != 'emscripten'" }, - { name = "multidict", marker = "sys_platform != 'emscripten'" }, - { name = "propcache", marker = "sys_platform != 'emscripten'" }, - { name = "yarl", marker = "sys_platform != 'emscripten'" }, + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ @@ -150,8 +150,8 @@ name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist", marker = "sys_platform != 'emscripten'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' and sys_platform != 'emscripten'" }, + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -446,46 +446,58 @@ mcp = [ [[package]] name = "cbor2" -version = "5.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/05/486166d9e998d65d70810e63eeacc8c5f13d167d8797cf2d73a588beb335/cbor2-5.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2263c0c892194f10012ced24c322d025d9d7b11b41da1c357f3b3fe06676e6b7", size = 69882, upload-time = "2025-12-30T18:43:25.365Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d0/ee976eaaf21c211eef651e1a921c109c3c3a3785d98307d74a70d142f341/cbor2-5.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffe4ca079f6f8ed393f5c71a8de22651cb27bd50e74e2bcd6bc9c8f853a732b", size = 260696, upload-time = "2025-12-30T18:43:27.784Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/81cabd3aee6cc54b101a5214d5c3e541d275d7c05647c7dfc266c6aacf6f/cbor2-5.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0427bd166230fe4c4b72965c6f2b6273bf29016d97cf08b258fa48db851ea598", size = 252135, upload-time = "2025-12-30T18:43:29.418Z" }, - { url = "https://files.pythonhosted.org/packages/c2/0b/f38e8c579e7e2d88d446549bce35bde7d845199300bc456b4123d6e6f0af/cbor2-5.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c23a04947c37964d70028ca44ea2a8709f09b8adc0090f9b5710fa957e9bc545", size = 255342, upload-time = "2025-12-30T18:43:30.966Z" }, - { url = "https://files.pythonhosted.org/packages/5d/02/8413f1bd42c8f665fb85374151599cb4957848f0f307d08334a08dee544c/cbor2-5.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:218d5c7d2e8d13c7eded01a1b3fe2a9a1e51a7a843cefb8d38cb4bbbc6ad9bf7", size = 247191, upload-time = "2025-12-30T18:43:32.555Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b8/edeffcad06b83d3661827973a8e6f5d51a9f5842e1ee9d191fdef60388ad/cbor2-5.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:4ce7d907a25448af7c13415281d739634edfd417228b274309b243ca52ad71f9", size = 69254, upload-time = "2025-12-30T18:43:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/ce/1a/dde6537d8d1c2b3157ea6487ea417a5ad0157687d0e9a3ff806bf23c8cb1/cbor2-5.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:628d0ea850aa040921a0e50a08180e7d20cf691432cec3eabc193f643eccfbde", size = 64946, upload-time = "2025-12-30T18:43:34.849Z" }, - { url = "https://files.pythonhosted.org/packages/88/4b/623435ef9b98e86b6956a41863d39ff4fe4d67983948b5834f55499681dd/cbor2-5.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18ac191640093e6c7fbcb174c006ffec4106c3d8ab788e70272c1c4d933cbe11", size = 69875, upload-time = "2025-12-30T18:43:35.888Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/f664201080b2a7d0f57c16c8e9e5922013b92f202e294863ec7e75b7ff7f/cbor2-5.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fddee9103a17d7bed5753f0c7fc6663faa506eb953e50d8287804eccf7b048e6", size = 268316, upload-time = "2025-12-30T18:43:37.161Z" }, - { url = "https://files.pythonhosted.org/packages/d0/e1/072745b4ff01afe9df2cd627f8fc51a1acedb5d3d1253765625d2929db91/cbor2-5.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d2ea26fad620aba5e88d7541be8b10c5034a55db9a23809b7cb49f36803f05b", size = 258874, upload-time = "2025-12-30T18:43:38.878Z" }, - { url = "https://files.pythonhosted.org/packages/a7/10/61c262b886d22b62c56e8aac6d10fa06d0953c997879ab882a31a624952b/cbor2-5.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:de68b4b310b072b082d317adc4c5e6910173a6d9455412e6183d72c778d1f54c", size = 261971, upload-time = "2025-12-30T18:43:40.401Z" }, - { url = "https://files.pythonhosted.org/packages/7e/42/b7862f5e64364b10ad120ea53e87ec7e891fb268cb99c572348e647cf7e9/cbor2-5.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:418d2cf0e03e90160fa1474c05a40fe228bbb4a92d1628bdbbd13a48527cb34d", size = 254151, upload-time = "2025-12-30T18:43:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/16/6a/8d3636cf75466c18615e7cfac0d345ee3c030f6c79535faed0c2c02b1839/cbor2-5.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:453200ffa1c285ea46ab5745736a015526d41f22da09cb45594624581d959770", size = 69169, upload-time = "2025-12-30T18:43:43.424Z" }, - { url = "https://files.pythonhosted.org/packages/9b/88/79b205bf869558b39a11de70750cb13679b27ba5654a43bed3f2aee7d1b4/cbor2-5.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:f6615412fca973a8b472b3efc4dab01df71cc13f15d8b2c0a1cffac44500f12d", size = 64955, upload-time = "2025-12-30T18:43:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4f/3a16e3e8fd7e5fd86751a4f1aad218a8d19a96e75ec3989c3e95a8fe1d8f/cbor2-5.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3f91fa699a5ce22470e973601c62dd9d55dc3ca20ee446516ac075fcab27c9", size = 70270, upload-time = "2025-12-30T18:43:46.005Z" }, - { url = "https://files.pythonhosted.org/packages/38/81/0d0cf0796fe8081492a61c45278f03def21a929535a492dd97c8438f5dbe/cbor2-5.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518c118a5e00001854adb51f3164e647aa99b6a9877d2a733a28cb5c0a4d6857", size = 286242, upload-time = "2025-12-30T18:43:47.026Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/fdab6c10190cfb8d639e01f2b168f2406fc847a2a6bc00e7de78c3381d0a/cbor2-5.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cff2a1999e49cd51c23d1b6786a012127fd8f722c5946e82bd7ab3eb307443f3", size = 285412, upload-time = "2025-12-30T18:43:48.563Z" }, - { url = "https://files.pythonhosted.org/packages/31/59/746a8e630996217a3afd523f583fcf7e3d16640d63f9a03f0f4e4f74b5b1/cbor2-5.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c4492160212374973cdc14e46f0565f2462721ef922b40f7ea11e7d613dfb2a", size = 278041, upload-time = "2025-12-30T18:43:49.92Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a3/f3bbeb6dedd45c6e0cddd627ea790dea295eaf82c83f0e2159b733365ebd/cbor2-5.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:546c7c7c4c6bcdc54a59242e0e82cea8f332b17b4465ae628718fef1fce401ca", size = 278185, upload-time = "2025-12-30T18:43:51.192Z" }, - { url = "https://files.pythonhosted.org/packages/67/e5/9013d6b857ceb6cdb2851ffb5a887f53f2bab934a528c9d6fa73d9989d84/cbor2-5.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:074f0fa7535dd7fdee247c2c99f679d94f3aa058ccb1ccf4126cc72d6d89cbae", size = 69817, upload-time = "2025-12-30T18:43:52.352Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ab/7aa94ba3d44ecbc3a97bdb2fb6a8298063fe2e0b611e539a6fe41e36da20/cbor2-5.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:f95fed480b2a0d843f294d2a1ef4cc0f6a83c7922927f9f558e1f5a8dc54b7ca", size = 64923, upload-time = "2025-12-30T18:43:53.719Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" }, - { url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" }, - { url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d2/d41f8c04c783a4d204e364be2d38043d4f732a3bed6f4c732e321cf34c7b/cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0", size = 69841, upload-time = "2025-12-30T18:44:01.159Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8c/0397a82f6e67665009951453c83058e4c77ba54b9a9017ede56d6870306c/cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea", size = 64982, upload-time = "2025-12-30T18:44:02.138Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" }, - { url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" }, - { url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" }, - { url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" }, - { url = "https://files.pythonhosted.org/packages/88/6d/e43452347630efe8133f5304127539100d937c138c0996d27ec63963ec2c/cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1", size = 71657, upload-time = "2025-12-30T18:44:10.162Z" }, - { url = "https://files.pythonhosted.org/packages/8b/66/9a780ef34ab10a0437666232e885378cdd5f60197b1b5e61a62499e5a10a/cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71", size = 67171, upload-time = "2025-12-30T18:44:11.619Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" }, +version = "6.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/7d/91e6b680e8ae313e53ae8da1d0b578c668d240c9f294be06d729fc014854/cbor2-6.1.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3cfb7eec57406b86aab47183f6dc90ece888e03721d37a4d33315e79e486898d", size = 415427, upload-time = "2026-08-01T20:40:24.371Z" }, + { url = "https://files.pythonhosted.org/packages/6b/43/3d8d53f22be9566cbb011bdaaf799a77f52e447b60a54efc9537f271f177/cbor2-6.1.4-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ae14eb3f2f251102d72625ab02a28c83a8d11adf3d0383bd6d7bd9d672e18119", size = 458280, upload-time = "2026-08-01T20:40:26.362Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d8/0b2fb7e936b63395ab1acf91074dc0788c575a57e8d808d9e8489de631f4/cbor2-6.1.4-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7b697c2a18eab9a3326447fe0f1c927db065a096febc57f064f0f5ed67fe96fc", size = 464737, upload-time = "2026-08-01T20:40:27.897Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f794155ec864c0e85a2df293adc589e7e45133ab15c0d21ac5ae3a51f804/cbor2-6.1.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:032ce71cbdc9267e9cec42132f6ac6c40f441ec27ebc87ca9dd209dcab6804ee", size = 524277, upload-time = "2026-08-01T20:40:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/819912a5192ffdb0261b9c73e57b2ee77b4141132ffac77bf374c738e310/cbor2-6.1.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e8097e8259b78c8feb5dd5b833b23f83ed588e821ef44cf1455082cfc9434440", size = 533360, upload-time = "2026-08-01T20:40:30.922Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f4/73aaf73837bae5c47f01e1a602214a9b9ece6c0687b7b1d6e4ecafe55b74/cbor2-6.1.4-cp310-cp310-win32.whl", hash = "sha256:41aae72e8863ac0d50705ea8290b72c49db90a6d953110feaa91360ae526fa8d", size = 282266, upload-time = "2026-08-01T20:40:32.576Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a9/a1a88346b44a7fbcd4b4aa4f527fbc5f46649bf0706e994d8e59aada4641/cbor2-6.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:deff027a56e04caebfee649029bfd1836f4fb468d2cf66d2a396ae1018f655db", size = 303781, upload-time = "2026-08-01T20:40:34.198Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/969a945d3f3c0bd619d73e7eb375ef1800af33451d7ece2d56d881ef1732/cbor2-6.1.4-cp310-cp310-win_arm64.whl", hash = "sha256:c435720a25c1de9b241df883b5890b35aff3bf64365efc8037d4d26df085724e", size = 296316, upload-time = "2026-08-01T20:40:35.885Z" }, + { url = "https://files.pythonhosted.org/packages/a7/84/1e363301c06f509963d134f5479e82b3ade87fb1495ddacf9bf7ff24ac42/cbor2-6.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2", size = 414947, upload-time = "2026-08-01T20:40:37.405Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/d8e1ed3e79ea20a3423a96b5c89ce794fa02cb428e4429e601f8ebcbac7c/cbor2-6.1.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0", size = 457343, upload-time = "2026-08-01T20:40:38.868Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/5796c2ed2dcd0696fc4abedf0ea0dfd5361b3f022a311481f977fa51b2b8/cbor2-6.1.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e", size = 464314, upload-time = "2026-08-01T20:40:40.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/88/de524c6c2c91b740e5df6e6955a113fb616e979b26fd2e6a0693082d36e0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195", size = 523053, upload-time = "2026-08-01T20:40:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/84/07/cb5fd92834633508d680a5b5695aeaf99d33ca0bdc5b844550d538f335b0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a", size = 532177, upload-time = "2026-08-01T20:40:44.058Z" }, + { url = "https://files.pythonhosted.org/packages/c9/19/be98721365edfe6fc23e6bcd1385afa0e960b247c5f0b50bb67f5d05e2d9/cbor2-6.1.4-cp311-cp311-win32.whl", hash = "sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8", size = 281660, upload-time = "2026-08-01T20:40:45.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/23/d54f679d4b155918f5a0879dab78203ce4fd514d311b7cfeba27dafe480b/cbor2-6.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9", size = 303207, upload-time = "2026-08-01T20:40:47.293Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/b3839d6213c88b249ba860525df05ff18b27bdc28ebc09cb1547790f001a/cbor2-6.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8", size = 296123, upload-time = "2026-08-01T20:40:48.808Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/fb64293c19cafb860060310c57b768fd9cfb7cf592449660b756538cc116/cbor2-6.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b", size = 404608, upload-time = "2026-08-01T20:40:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/96/ac/f58b3bafce7c86ada2ad8eaf189453136d2cf5bae526ea0540e1b9bc9d06/cbor2-6.1.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2", size = 449851, upload-time = "2026-08-01T20:40:51.725Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/10c6c126d59b07f2bd005094dd12a20afa46146f7e2673ed6f61a57641a7/cbor2-6.1.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220", size = 461193, upload-time = "2026-08-01T20:40:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/15/e4/4445e6237088d1cca3b8536daeb90d6b4e23776de5609c9fa46773874757/cbor2-6.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f", size = 516937, upload-time = "2026-08-01T20:40:54.952Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/9c0959510f7a402e5995c81ccfd82cb9f314140dc0cce88c12836e5b93f1/cbor2-6.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242", size = 529229, upload-time = "2026-08-01T20:40:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/91/8e/6811e4ee84203ac657f6f461a37c7c9ba0287bde80eb83c7971e9b3fe156/cbor2-6.1.4-cp312-cp312-win32.whl", hash = "sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e", size = 278810, upload-time = "2026-08-01T20:40:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/da/27/87440788fc0d9513534c3c699238e2a9ca6010f8cb72e9c203b7af20a9f6/cbor2-6.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6", size = 299971, upload-time = "2026-08-01T20:40:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/77981e6e63092de19d7306a09a12b0eb3fd2907dc22c10dd5d389eb27faf/cbor2-6.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b", size = 290662, upload-time = "2026-08-01T20:41:00.89Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" }, + { url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" }, + { url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" }, + { url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" }, + { url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" }, + { url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" }, + { url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" }, + { url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" }, + { url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" }, + { url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" }, ] [[package]] @@ -930,7 +942,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1131,7 +1143,7 @@ name = "grpcio" version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } wheels = [ @@ -1872,12 +1884,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, + { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, + { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + +[[package]] +name = "msgspec-cbor" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2" }, + { name = "msgspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/6d/d74123817748e5588020ce55ad80b8c629bc29250e19fa4c1fe113879bf3/msgspec_cbor-0.2.0.tar.gz", hash = "sha256:4e340d01e533c9190998dd54a460d0c05306f1afb04eccedf77be1c3962c83b5", size = 114626, upload-time = "2026-07-15T23:07:03.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/eb/2da515d6356f972540178fefa09adad57a375604a2fdddbee3c81c7173bb/msgspec_cbor-0.2.0-py3-none-any.whl", hash = "sha256:87e506b067bc8a7a0ae60bc307f48f3258b0ace4bd349023ea97d5e6e857d48a", size = 4617, upload-time = "2026-07-15T23:07:02.12Z" }, +] + [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11' and sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ @@ -2137,7 +2218,7 @@ name = "prettytable" version = "3.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth", marker = "sys_platform != 'emscripten'" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } wheels = [ @@ -2149,7 +2230,7 @@ name = "prompt-toolkit" version = "3.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth", marker = "sys_platform != 'emscripten'" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ @@ -2550,7 +2631,7 @@ name = "pyobjc-framework-cocoa" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } wheels = [ @@ -2568,8 +2649,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157, upload-time = "2025-11-14T10:13:28.064Z" } wheels = [ @@ -2587,8 +2668,8 @@ name = "pyobjc-framework-libdispatch" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277, upload-time = "2025-11-14T10:16:46.235Z" } wheels = [ @@ -2646,7 +2727,7 @@ name = "pyserial-asyncio" version = "0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyserial", marker = "sys_platform != 'emscripten'" }, + { name = "pyserial" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/9a/8477699dcbc1882ea51dcff4d3c25aa3f2063ed8f7d7a849fd8f610506b6/pyserial-asyncio-0.6.tar.gz", hash = "sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f", size = 31322, upload-time = "2021-09-30T22:29:02.174Z" } wheels = [ @@ -3040,11 +3121,11 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'android'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.14' and sys_platform == 'android'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'android'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", ] sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } @@ -3210,8 +3291,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -3238,16 +3319,14 @@ wheels = [ [[package]] name = "smp" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } +version = "4.1.1.dev14+gb90032996" +source = { git = "https://github.com/JPHutchins/smp?rev=screaming-goblin#b90032996b1b7a595006d0ca04bf7fafc421fc80" } dependencies = [ { name = "cbor2" }, { name = "crcmod" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/3a/2a5017d4ed321389f242f26392d1cadfa36d9c6f235206b26491df83aafb/smp-4.1.0.tar.gz", hash = "sha256:def270346a9f67e99e526a3c789a814e6e2203afa4d913cb11bb5a9bda6423a9", size = 26936, upload-time = "2026-06-03T02:00:01.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/c5/8a6625c8afd8b96865546310da0ac6b83a5700a9892c0e9b923b0ff1af61/smp-4.1.0-py3-none-any.whl", hash = "sha256:ca06376b130f028e02dc147a82f1f32bd1ce8e9397b23a0d6d391f7de76e883c", size = 32969, upload-time = "2026-06-03T02:00:00.014Z" }, + { name = "msgspec" }, + { name = "msgspec-cbor" }, + { name = "types-bits" }, ] [[package]] @@ -3256,7 +3335,9 @@ source = { editable = "." } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "intelhex" }, + { name = "msgspec" }, { name = "smp" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] @@ -3317,11 +3398,13 @@ requires-dist = [ { name = "intelhex", specifier = ">=2.3.0" }, { name = "libusb1", marker = "extra == 'all'", specifier = ">=3.1,<4" }, { name = "libusb1", marker = "extra == 'bumble'", specifier = ">=3.1,<4" }, + { name = "msgspec", specifier = ">=0.21.1" }, { name = "platformdirs", marker = "extra == 'all'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'bumble'", specifier = ">=4,<5" }, { name = "pyserial", marker = "extra == 'all'", specifier = ">=3.5" }, { name = "pyserial", marker = "extra == 'serial'", specifier = ">=3.5" }, - { name = "smp", specifier = ">=4.1.0" }, + { name = "smp", git = "https://github.com/JPHutchins/smp?rev=screaming-goblin" }, + { name = "typing-extensions", specifier = ">=4.10" }, { name = "zephyr-4-4-0-hci", marker = "extra == 'all'", specifier = ">=0.1.3,<1" }, { name = "zephyr-4-4-0-hci", marker = "extra == 'hci-firmware'", specifier = ">=0.1.3,<1" }, ] @@ -3382,8 +3465,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [ @@ -3396,10 +3479,10 @@ version = "2.2.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.11' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'android'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11' and sys_platform == 'android'", ] sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } @@ -3502,6 +3585,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" }, ] +[[package]] +name = "types-bits" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/f6/2a6f942fd4b9324fa2cead74f7b85a21ccbdd0ddddce6de87bf00ac528e7/types_bits-0.2.0.tar.gz", hash = "sha256:0e9a45d6614551043880e5f47c70981b39d78bc1c2a018d1ad4324bccf60079c", size = 119348, upload-time = "2026-08-28T22:19:19.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/41/cf41a0415d18b66378abc956034e417acccef3462888c173c8d53ed4ff87/types_bits-0.2.0-py3-none-any.whl", hash = "sha256:810b8e773b0ffd80fb589185abc21641fddba234a8ebbb7ce55f3f5d6dc8cf6d", size = 13716, upload-time = "2026-08-28T22:19:18.886Z" }, +] + [[package]] name = "types-pyserial" version = "3.5.0.20251001" @@ -3583,9 +3675,9 @@ name = "uvicorn" version = "0.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform != 'emscripten'" }, - { name = "h11", marker = "sys_platform != 'emscripten'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11' and sys_platform != 'emscripten'" }, + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ @@ -3731,7 +3823,7 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -3757,7 +3849,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -3783,7 +3875,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -3809,7 +3901,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -3835,7 +3927,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -3861,7 +3953,7 @@ name = "winrt-windows-devices-radios" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } wheels = [ @@ -3887,7 +3979,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -3913,7 +4005,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -3939,7 +4031,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [ @@ -3965,9 +4057,9 @@ name = "yarl" version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform != 'emscripten'" }, - { name = "multidict", marker = "sys_platform != 'emscripten'" }, - { name = "propcache", marker = "sys_platform != 'emscripten'" }, + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } wheels = [