From 368bcbee790ded60649d36d30019211591a4d5f8 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Fri, 28 Aug 2026 14:01:12 -0700 Subject: [PATCH 1/5] breaking: port to smp screaming-goblin (Frame[T] on msgspec) smp's `screaming-goblin` replaces the flattened pydantic message model with composition -- a message is a `Frame[T] = (Header, Data[T])` on `msgspec.Struct` -- and bakes each request's `_Response`/`_ErrorV1`/`_ErrorV2` binding into the request class itself. This ports smpclient onto it. Part of intercreate/smpmgr#103; closes #124. Net -872 lines. ## The request layer is gone (#124) `smpclient/requests/**` was a parallel class layer that existed only to attach `_Response`/`_ErrorV1`/`_ErrorV2` to each smp request. smp does that itself now, so the layer is deleted outright rather than deprecated: `screaming-goblin` is the breaking release. Callers move to smp's own names, which differ -- `GroupCountRequest` rather than `CountSupportedGroups`, `ImageStatesReadRequest` rather than `ImageStatesRead` -- across 25 files here plus smpmgr. `SMPRequest` now comes from `smp`. The four narrowers stay in `smpclient.generics`: smp deliberately does not ship them, as its own typing test says outright ("without the `smp` library having to ship them"). Their signatures are unchanged. smp's non-generic `TypeIs` narrowers were tried first and rejected: they erode `TRep` to `ReadResponse | WriteResponse` for a *generic* caller, which breaks the `ensure_request` helper both `examples/*/ upgrade.py` are built around. smp's typing test only exercises concrete request types, so it does not cover that case. The cost of keeping the generic form is 5 `reportInvalidTypeVarUse` warnings, which do not gate (#134). ## request() The frame is now built once, because it carries the sequence the response must echo: request_frame = request.to_frame() ... send bytes(request_frame) ... if header.sequence != request_frame.header.sequence: raise SMPBadSequence `loads()` returns a `Frame`, so the client returns `.data`. The decode chain catches `msgspec.DecodeError`, not `ValidationError`. Both are reachable and the wider one is deliberate: a *schema* mismatch raises `ValidationError`, but a payload that is not decodable CBOR at all -- truncated, or empty -- raises a bare `DecodeError`. Catching only `ValidationError` would let a truncated response escape as a raw msgspec traceback instead of the `SMPValidationException` carrying the header and hexdump. Under smp 4.x a raw cbor2 error escaped uncaught, so this is also a small improvement. `SMPMalformed` and `SMPMismatchedGroupId` are *not* caught. They fail all three candidate types identically, which makes them transport errors rather than "this frame matched no schema", and swallowing them into the try-chain would report a group mismatch as three parse failures. Both cases are now covered by tests. msgspec reports a decode failure as a single message rather than a structured list, so `_format_validation_error` is gone and `_validation_failure` prints the message; the header and hexdump it reports are unchanged. ## Size math `bytes(Data)` is the CBOR payload alone -- the old `bytes(message)` included the 8-byte header -- so `get_max_cbor_and_data_size` subtracts `Header.SIZE` explicitly and derives the CBOR size from `len(bytes(request))` rather than the header's `length` field. `_maximize_upload_packet` collapses to `msgspec.structs.replace(request, data=...)`: `to_frame()` computes `length` from the actual payload, so there is no header to build and no field-carrying to do. `_ic_maximize_packet` was a hand-rolled copy of that logic, needed only because pydantic made field-carrying manual; it is deleted and `ICUploadClient` calls the generic one, which required adding the Intercreate request to `TUploadRequest`. The byte-exact expectations in `test_maximize_upload_packet_fills_decoded_buffer` were left untouched and pass: the maximizer still fills `max_unencoded_size` exactly for buf_size 384/512/1024/2048. ## Tests `SMPMockTransport` now echoes the request's sequence back on the response, the way a real server does. `request()` draws the sequence from smp's counter when it frames, so a test cannot know it in advance -- and with the echo, the 22 hand-built `smphdr.Header(...)` blocks and all the `(h.sequence + 2) % 0xFF` bookkeeping simply disappear. A `sequence_offset` fakes a server answering out of order, for the `SMPBadSequence` case. `ErrorV1`/`ErrorV2` declare no `_OP` or `_COMMAND_ID`, so `to_frame()` cannot synthesize their header and a test that needs their bytes builds it by hand -- the same thing smp's own `test_error.py` does. That is what `error_bytes()` is. `tests/test_requests.py` tested the deleted layer and is deleted with it. ## Docs Deleting the request layer removes the only reason `docs/_generate_requests_docstrings.py` existed -- it back-filled inherited docstrings onto those subclasses. The script, its step in both `release.yaml` and `test-docs.yaml`, the seven per-group pages and their nav entries all go. `docs/requests.md` stays, because the narrowers it documents stay, and `docs/user/intercreate.md` now points at `smpclient.extensions.intercreate`, which is genuinely smpclient's. ## Dependency `smp` is pinned to the `screaming-goblin` branch by direct reference (hatchling needs `allow-direct-references` for that), because smp is not released with the break. Both are marked TODO to undo at release. `msgspec` is now declared: this module imports it directly for `DecodeError` and `structs.replace`, and relying on it arriving through smp is the same undeclared-transitive mistake #133 just fixed for pydantic. `typing_extensions` is now declared too. It is imported at runtime by twelve modules -- `generics` needs `TypeIs`, and the transports need `override` and `assert_never` -- but it was never declared: it arrived transitively via pydantic, via smp 4.x. Dropping pydantic from the tree took it with it, which the `transport-extras` CI job caught (`uv pip install .` resolves only declared dependencies, unlike the lockfile the other jobs use). That is the third undeclared-transitive dependency in this effort, after pydantic and msgspec. `smpclient/__init__.py` no longer imports pydantic, so with #133 the dependency is gone from `src/` entirely. ## Verification `camas matrix` green on Python 3.10-3.14. The integration suite passes 229/229 against real Zephyr servers -- serial, raw serial, COBS raw, UDP, and MCUboot serial recovery -- which is what establishes that the msgspec encoder puts the same bytes on the wire. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yaml | 1 - .github/workflows/test-docs.yaml | 1 - docs/_generate_requests_docstrings.py | 177 ------- docs/file_management.md | 6 - docs/image_management.md | 6 - docs/os_management.md | 6 - docs/settings_management.md | 6 - docs/shell_management.md | 6 - docs/statistics_management.md | 6 - docs/user/intercreate.md | 2 +- docs/zephyr_management.md | 6 - examples/ble/helloworld.py | 5 +- examples/ble/imagestate.py | 5 +- examples/ble/mcumgrparameters.py | 5 +- examples/ble/upgrade.py | 17 +- examples/ble/upload.py | 7 +- examples/udp/helloworld.py | 5 +- examples/usb/helloworld.py | 5 +- examples/usb/upgrade.py | 18 +- mkdocs.yaml | 7 - pyproject.toml | 10 +- src/smpclient/__init__.py | 130 +++-- src/smpclient/extensions/intercreate.py | 36 +- src/smpclient/generics.py | 31 +- src/smpclient/requests/__init__.py | 0 .../requests/enumeration_management.py | 22 - src/smpclient/requests/file_management.py | 30 -- src/smpclient/requests/image_management.py | 22 - src/smpclient/requests/os_management.py | 42 -- src/smpclient/requests/settings_management.py | 30 -- src/smpclient/requests/shell_management.py | 10 - .../requests/statistics_management.py | 14 - src/smpclient/requests/user/__init__.py | 0 src/smpclient/requests/user/intercreate.py | 10 - src/smpclient/requests/zephyr_management.py | 10 - src/smpclient/transport/bumble/__main__.py | 4 +- tests/extensions/test_intercreate.py | 10 +- tests/integration/conftest.py | 15 +- tests/integration/test_enumeration.py | 6 +- tests/integration/test_fragmentation.py | 22 +- tests/integration/test_image_management.py | 6 +- tests/integration/test_os_management.py | 6 +- tests/integration/test_serial_recovery.py | 8 +- tests/integration/test_udp.py | 4 +- tests/test_requests.py | 340 ------------ tests/test_smp_ble_transport.py | 10 +- tests/test_smp_bumble_transport.py | 6 +- tests/test_smp_client.py | 484 +++++++----------- tests/test_smp_serial_raw_transport.py | 101 ++-- tests/test_smp_serial_transport.py | 56 +- tests/test_smp_udp_transport.py | 4 +- uv.lock | 178 +++++-- 52 files changed, 548 insertions(+), 1406 deletions(-) delete mode 100644 docs/_generate_requests_docstrings.py delete mode 100644 docs/file_management.md delete mode 100644 docs/image_management.md delete mode 100644 docs/os_management.md delete mode 100644 docs/settings_management.md delete mode 100644 docs/shell_management.md delete mode 100644 docs/statistics_management.md delete mode 100644 docs/zephyr_management.md delete mode 100644 src/smpclient/requests/__init__.py delete mode 100644 src/smpclient/requests/enumeration_management.py delete mode 100644 src/smpclient/requests/file_management.py delete mode 100644 src/smpclient/requests/image_management.py delete mode 100644 src/smpclient/requests/os_management.py delete mode 100644 src/smpclient/requests/settings_management.py delete mode 100644 src/smpclient/requests/shell_management.py delete mode 100644 src/smpclient/requests/statistics_management.py delete mode 100644 src/smpclient/requests/user/__init__.py delete mode 100644 src/smpclient/requests/user/intercreate.py delete mode 100644 src/smpclient/requests/zephyr_management.py delete mode 100644 tests/test_requests.py 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/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..c093b09 100644 --- a/examples/ble/helloworld.py +++ b/examples/ble/helloworld.py @@ -3,9 +3,10 @@ import asyncio from typing import Final +from smp.os_management import EchoWriteRequest + from smpclient import SMPClient from smpclient.generics import error, success -from smpclient.requests.os_management import EchoWrite from smpclient.transport.ble import SMPBLETransport @@ -20,7 +21,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..0a8cda5 100644 --- a/examples/ble/imagestate.py +++ b/examples/ble/imagestate.py @@ -3,9 +3,10 @@ import asyncio from typing import Final +from smp.image_management import ImageStatesReadRequest + from smpclient import SMPClient from smpclient.generics import error, success -from smpclient.requests.image_management import ImageStatesRead from smpclient.transport.ble import SMPBLETransport @@ -20,7 +21,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..1781679 100644 --- a/examples/ble/mcumgrparameters.py +++ b/examples/ble/mcumgrparameters.py @@ -3,9 +3,10 @@ import asyncio from typing import Final +from smp.os_management import MCUMgrParametersReadRequest + from smpclient import SMPClient from smpclient.generics import error, success -from smpclient.requests.os_management import MCUMgrParametersRead from smpclient.transport.ble import SMPBLETransport @@ -22,7 +23,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..e3a58f7 100644 --- a/examples/ble/upgrade.py +++ b/examples/ble/upgrade.py @@ -10,12 +10,13 @@ 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.generics import 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 +81,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 +97,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 +125,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..4ddabd7 100644 --- a/examples/ble/upload.py +++ b/examples/ble/upload.py @@ -6,9 +6,10 @@ import time from typing import Final +from smp.image_management import ImageStatesReadRequest + from smpclient import SMPClient from smpclient.generics import error, success -from smpclient.requests.image_management import ImageStatesRead from smpclient.transport.ble import SMPBLETransport logging.basicConfig( @@ -36,7 +37,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 +59,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..bf62bde 100644 --- a/examples/udp/helloworld.py +++ b/examples/udp/helloworld.py @@ -5,9 +5,10 @@ import logging from typing import Final +from smp.os_management import EchoWriteRequest + from smpclient import SMPClient from smpclient.generics import error, success -from smpclient.requests.os_management import EchoWrite from smpclient.transport.udp import SMPUDPTransport logging.basicConfig(level=logging.DEBUG) @@ -22,7 +23,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..b742313 100644 --- a/examples/usb/helloworld.py +++ b/examples/usb/helloworld.py @@ -4,9 +4,10 @@ import asyncio from typing import Final +from smp.os_management import EchoWriteRequest + from smpclient import SMPClient from smpclient.generics import error, success -from smpclient.requests.os_management import EchoWrite from smpclient.transport.serial import SMPSerialTransport @@ -19,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/usb/upgrade.py b/examples/usb/upgrade.py index 671e028..aae70e1 100644 --- a/examples/usb/upgrade.py +++ b/examples/usb/upgrade.py @@ -10,14 +10,14 @@ 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.generics import 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 +130,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 +155,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 +167,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 +199,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..2a1c8fd 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]", diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index 9db4a55..b10d59f 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -43,17 +43,18 @@ from types import TracebackType from typing import Final, TypeVar -from pydantic import ValidationError -from pydantic_core import ErrorDetails +import msgspec +from smp import SMPRequest from smp import header as smpheader from smp import message as smpmsg +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 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.generics import TEr1, TEr2, TRep, error, success from smpclient.transport import SMPTransport try: @@ -63,7 +64,12 @@ logger = logging.getLogger(__name__) -TUploadRequest = TypeVar("TUploadRequest", ImageUploadWrite, FileUpload) +TUploadRequest = TypeVar( + "TUploadRequest", + ImageUploadWriteRequest, + FileUploadRequest, + smpic.ImageUploadWriteRequest, +) """A single-shot upload request whose `data` field is filled to maximize throughput.""" @@ -79,18 +85,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 +101,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 ), ) @@ -131,12 +128,12 @@ class SMPClient: ```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=}") @@ -189,7 +186,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 +198,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 +222,11 @@ async def request( """ timeout_s = timeout_s if timeout_s is not None else self._timeout_s + request_frame: Final = request.to_frame() + 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 +234,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 +279,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 +302,7 @@ async def upload( response = await self.request( self._maximize_upload_packet( - ImageUploadWrite( + ImageUploadWriteRequest( off=0, data=b"", image=slot, @@ -327,7 +328,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 +368,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 +380,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 +399,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 +421,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 +431,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 +450,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 +504,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 +521,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 +530,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..3a7bfa8 100644 --- a/src/smpclient/extensions/intercreate.py +++ b/src/smpclient/extensions/intercreate.py @@ -1,14 +1,12 @@ """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.exceptions import SMPUploadError from smpclient.generics import error, success -from smpclient.requests.user import intercreate as ic class ICUploadClient(SMPClient): @@ -17,7 +15,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 +28,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 +38,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 index 89e7b3d..3064131 100644 --- a/src/smpclient/generics.py +++ b/src/smpclient/generics.py @@ -1,9 +1,8 @@ """Generics and Type Narrowing for SMP Requests and Responses.""" -from typing import Protocol, TypeVar, Union +from typing import 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 @@ -17,34 +16,6 @@ """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`. 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..4fa3fd1 100644 --- a/src/smpclient/transport/bumble/__main__.py +++ b/src/smpclient/transport/bumble/__main__.py @@ -9,11 +9,11 @@ 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.transport.bumble import SMPBumbleTransport from smpclient.transport.bumble.pairing import ( KeyboardOnly, @@ -97,7 +97,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..3a9c596 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())) + 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..db9c9a4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,14 +20,13 @@ 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.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 +143,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 +244,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 +257,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..96fad97 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 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..2d7bc12 100644 --- a/tests/integration/test_fragmentation.py +++ b/tests/integration/test_fragmentation.py @@ -10,10 +10,10 @@ 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.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 +51,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 +69,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 +89,13 @@ 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 + text = "M" * (transport.max_unencoded_size - len(bytes(EchoWriteRequest(d="").to_frame())) - 4) + request = EchoWriteRequest(d=text) + assert len(bytes(request.to_frame())) <= transport.max_unencoded_size - line_packets = len(list(smppacket.encode(request.BYTES, line_length=transport._line_length))) + line_packets = len( + list(smppacket.encode(bytes(request.to_frame()), line_length=transport._line_length)) + ) limit = cs.fixture.max_reliable_line_packets if limit is not None and line_packets > limit: pytest.skip( @@ -127,9 +129,9 @@ 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()), line_length=512))) < len( + list(smppacket.encode(bytes(request.to_frame()), line_length=128)) ) response = await client.request(request, timeout_s=10.0) assert success(response) diff --git a/tests/integration/test_image_management.py b/tests/integration/test_image_management.py index f80914d..f8b7503 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.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..6ff0f26 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 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..63663b9 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.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..8e262c3 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.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_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..8661192 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,16 @@ ) from smp.os_management import ( OS_MGMT_RET_RC, + EchoWriteResponse, OSManagementErrorV1, OSManagementErrorV2, + ResetWriteRequest, ResetWriteResponse, ) from smpclient import SMPClient 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 +52,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 +88,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 +100,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 +148,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()) 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 +160,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()) + 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 +184,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 +212,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()) with pytest.raises(SMPValidationException) as exc_info: await s.request(req) @@ -190,6 +227,52 @@ async def test_request_unparseable_frame() -> None: assert req._ErrorV2.__name__ in exc_info.value.details +@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()) + + 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 +286,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 +303,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 +316,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 +354,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 +406,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())) + return ImageUploadWriteResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -395,7 +431,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 +464,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())) + return ImageUploadWriteResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -446,7 +482,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 +500,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 +515,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 +529,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 +536,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 +566,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 +600,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 +651,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())) + return ImageUploadWriteResponse(off=request.off + len(request.data)) # type: ignore # noqa s.request = mock_request # type: ignore @@ -676,7 +676,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 +708,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 +789,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 +807,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 +826,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 +856,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 +867,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()) + # 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..09c1bbe 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() + 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())) 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())) @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()) 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..c0e00d9 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() + 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..0f8036e 100644 --- a/uv.lock +++ b/uv.lock @@ -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]] @@ -1872,6 +1884,75 @@ 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" @@ -3238,16 +3319,13 @@ wheels = [ [[package]] name = "smp" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } +version = "4.1.1.dev11+g6bce38bc5" +source = { git = "https://github.com/JPHutchins/smp?rev=screaming-goblin#6bce38bc5e82279605faecbb00cb30f1e45f453f" } 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" }, ] [[package]] @@ -3256,7 +3334,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 +3397,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" }, ] From 17592136e93f9c51e09725d6358fd61daff8d3a8 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Fri, 28 Aug 2026 14:28:03 -0700 Subject: [PATCH 2/5] refactor: address review -- narrowers to top level, typing tests, docs links Three of the review items from #137. ## `smpclient.generics` is gone The four narrowers and the `TRep`/`TEr1`/`TEr2` TypeVars move into `smpclient/__init__.py`, beside the `request()` whose return type they narrow. The module was 64 lines of which the `SMPRequest` Protocol -- the only part that justified a separate module -- had already moved to smp. ## Static exhaustiveness tests `tests/test_generics_typing.py` drives the real `SMPClient.request` and asserts the narrowing for every group, verified under mypy *and* pyright. `assert_type` pins the exact narrowed type rather than merely asserting it is a response, so a binding that silently widened would fail; `assert_never` closes each union. That widening is not hypothetical, and this file is what proves the choice made in the parent commit: with smp's non-generic `TypeIs` narrowers, `assert_type(response, EchoWriteResponse)` fails because `success()` yields `ReadResponse | WriteResponse` instead of the request's own response type. The file is never executed, so it is omitted from coverage -- there is no runtime behaviour in it to measure. ## docs/requests.md Now carries the per-group links to smp's documentation, replacing the seven deleted pages that used to hold them, plus the narrowing example and the helpers themselves. Co-Authored-By: Claude Opus 5 (1M context) --- docs/requests.md | 47 +++++++- examples/ble/helloworld.py | 3 +- examples/ble/imagestate.py | 3 +- examples/ble/mcumgrparameters.py | 3 +- examples/ble/upgrade.py | 3 +- examples/ble/upload.py | 3 +- examples/udp/helloworld.py | 3 +- examples/usb/helloworld.py | 3 +- examples/usb/upgrade.py | 3 +- pyproject.toml | 4 +- src/smpclient/__init__.py | 64 ++++++++++- src/smpclient/extensions/intercreate.py | 3 +- src/smpclient/generics.py | 64 ----------- src/smpclient/transport/bumble/__main__.py | 3 +- tests/integration/conftest.py | 3 +- tests/integration/test_enumeration.py | 2 +- tests/integration/test_fragmentation.py | 3 +- tests/integration/test_image_management.py | 2 +- tests/integration/test_os_management.py | 2 +- tests/integration/test_serial_recovery.py | 2 +- tests/integration/test_udp.py | 2 +- tests/test_generics_typing.py | 123 +++++++++++++++++++++ tests/test_smp_client.py | 3 +- 23 files changed, 248 insertions(+), 103 deletions(-) delete mode 100644 src/smpclient/generics.py create mode 100644 tests/test_generics_typing.py 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/examples/ble/helloworld.py b/examples/ble/helloworld.py index c093b09..b9c0341 100644 --- a/examples/ble/helloworld.py +++ b/examples/ble/helloworld.py @@ -5,8 +5,7 @@ from smp.os_management import EchoWriteRequest -from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport diff --git a/examples/ble/imagestate.py b/examples/ble/imagestate.py index 0a8cda5..f9b8f30 100644 --- a/examples/ble/imagestate.py +++ b/examples/ble/imagestate.py @@ -5,8 +5,7 @@ from smp.image_management import ImageStatesReadRequest -from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport diff --git a/examples/ble/mcumgrparameters.py b/examples/ble/mcumgrparameters.py index 1781679..57d0134 100644 --- a/examples/ble/mcumgrparameters.py +++ b/examples/ble/mcumgrparameters.py @@ -5,8 +5,7 @@ from smp.os_management import MCUMgrParametersReadRequest -from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport diff --git a/examples/ble/upgrade.py b/examples/ble/upgrade.py index e3a58f7..1b68d19 100644 --- a/examples/ble/upgrade.py +++ b/examples/ble/upgrade.py @@ -14,8 +14,7 @@ from smp.image_management import ImageStatesReadRequest, ImageStatesWriteRequest from smp.os_management import ResetWriteRequest -from smpclient import SMPClient -from smpclient.generics import TEr1, TEr2, TRep, error, success +from smpclient import SMPClient, TEr1, TEr2, TRep, error, success from smpclient.mcuboot import IMAGE_TLV, ImageInfo from smpclient.transport.ble import SMPBLETransport diff --git a/examples/ble/upload.py b/examples/ble/upload.py index 4ddabd7..025cb92 100644 --- a/examples/ble/upload.py +++ b/examples/ble/upload.py @@ -8,8 +8,7 @@ from smp.image_management import ImageStatesReadRequest -from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient import SMPClient, error, success from smpclient.transport.ble import SMPBLETransport logging.basicConfig( diff --git a/examples/udp/helloworld.py b/examples/udp/helloworld.py index bf62bde..799f8db 100644 --- a/examples/udp/helloworld.py +++ b/examples/udp/helloworld.py @@ -7,8 +7,7 @@ from smp.os_management import EchoWriteRequest -from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient import SMPClient, error, success from smpclient.transport.udp import SMPUDPTransport logging.basicConfig(level=logging.DEBUG) diff --git a/examples/usb/helloworld.py b/examples/usb/helloworld.py index b742313..2373dd4 100644 --- a/examples/usb/helloworld.py +++ b/examples/usb/helloworld.py @@ -6,8 +6,7 @@ from smp.os_management import EchoWriteRequest -from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient import SMPClient, error, success from smpclient.transport.serial import SMPSerialTransport diff --git a/examples/usb/upgrade.py b/examples/usb/upgrade.py index aae70e1..9cc0e45 100644 --- a/examples/usb/upgrade.py +++ b/examples/usb/upgrade.py @@ -15,8 +15,7 @@ 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 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.transport.serial import BufferParams, SMPSerialTransport diff --git a/pyproject.toml b/pyproject.toml index 2a1c8fd..c5d9db3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,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 b10d59f..1ac3a33 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -41,20 +41,20 @@ from collections.abc import AsyncIterator from hashlib import sha256 from types import TracebackType -from typing import Final, TypeVar +from typing import Final, TypeVar, Union 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 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 assert_never +from typing_extensions import TypeIs, assert_never from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException -from smpclient.generics import TEr1, TEr2, TRep, error, success from smpclient.transport import SMPTransport try: @@ -64,6 +64,64 @@ logger = logging.getLogger(__name__) +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: 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 == smpmsg.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 == smpmsg.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 == smpmsg.ResponseType.SUCCESS + + TUploadRequest = TypeVar( "TUploadRequest", ImageUploadWriteRequest, diff --git a/src/smpclient/extensions/intercreate.py b/src/smpclient/extensions/intercreate.py index 3a7bfa8..c8a7343 100644 --- a/src/smpclient/extensions/intercreate.py +++ b/src/smpclient/extensions/intercreate.py @@ -4,9 +4,8 @@ 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 class ICUploadClient(SMPClient): diff --git a/src/smpclient/generics.py b/src/smpclient/generics.py deleted file mode 100644 index 3064131..0000000 --- a/src/smpclient/generics.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Generics and Type Narrowing for SMP Requests and Responses.""" - -from typing import TypeVar, Union - -from smp import error as smperror -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).""" - - -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/transport/bumble/__main__.py b/src/smpclient/transport/bumble/__main__.py index 4fa3fd1..06e3892 100644 --- a/src/smpclient/transport/bumble/__main__.py +++ b/src/smpclient/transport/bumble/__main__.py @@ -12,8 +12,7 @@ from smp.os_management import EchoWriteRequest from typing_extensions import assert_never -from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient import SMPClient, error, success from smpclient.transport.bumble import SMPBumbleTransport from smpclient.transport.bumble.pairing import ( KeyboardOnly, diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index db9c9a4..645e250 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -24,9 +24,8 @@ 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.transport import SMPTransport from smpclient.transport.serial import SMPSerialRawTransport, SMPSerialTransport from smpclient.transport.udp import SMPUDPTransport diff --git a/tests/integration/test_enumeration.py b/tests/integration/test_enumeration.py index 96fad97..e1fb254 100644 --- a/tests/integration/test_enumeration.py +++ b/tests/integration/test_enumeration.py @@ -6,7 +6,7 @@ from smp import header as smphdr from smp.enumeration_management import GroupCountRequest, ListOfGroupsRequest -from smpclient.generics import success +from smpclient import success from tests.integration.conftest import ConnectedServer pytestmark = [pytest.mark.integration, pytest.mark.asyncio] diff --git a/tests/integration/test_fragmentation.py b/tests/integration/test_fragmentation.py index 2d7bc12..6c715c0 100644 --- a/tests/integration/test_fragmentation.py +++ b/tests/integration/test_fragmentation.py @@ -12,8 +12,7 @@ from smp import packet as smppacket from smp.os_management import EchoWriteRequest -from smpclient import SMPClient -from smpclient.generics import success +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 diff --git a/tests/integration/test_image_management.py b/tests/integration/test_image_management.py index f8b7503..e8b9399 100644 --- a/tests/integration/test_image_management.py +++ b/tests/integration/test_image_management.py @@ -11,7 +11,7 @@ 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.transport.serial import SMPSerialRawTransport, SMPSerialTransport from tests.integration.conftest import ( diff --git a/tests/integration/test_os_management.py b/tests/integration/test_os_management.py index 6ff0f26..d86d572 100644 --- a/tests/integration/test_os_management.py +++ b/tests/integration/test_os_management.py @@ -5,7 +5,7 @@ import pytest from smp.os_management import EchoWriteRequest, MCUMgrParametersReadRequest -from smpclient.generics import success +from smpclient import success from tests.integration.conftest import ConnectedServer pytestmark = [pytest.mark.integration, pytest.mark.asyncio] diff --git a/tests/integration/test_serial_recovery.py b/tests/integration/test_serial_recovery.py index 63663b9..aae2b39 100644 --- a/tests/integration/test_serial_recovery.py +++ b/tests/integration/test_serial_recovery.py @@ -32,7 +32,7 @@ from smp.os_management import MCUMgrParametersReadRequest from typing_extensions import assert_never -from smpclient.generics import success +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 ( diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 8e262c3..8b5a60c 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -5,7 +5,7 @@ import pytest from smp.os_management import EchoWriteRequest -from smpclient.generics import success +from smpclient import success from smpclient.transport.udp import SMPUDPTransport from tests.integration.conftest import ConnectedServer 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_smp_client.py b/tests/test_smp_client.py index 8661192..2dad448 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -38,9 +38,8 @@ ResetWriteResponse, ) -from smpclient import SMPClient +from smpclient import SMPClient, error, error_v1, error_v2, success from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException -from smpclient.generics import error, error_v1, error_v2, success from smpclient.transport.serial import ( BufferParams, BufferSize, From 9c461ba64681c2ddc5d508517486e9645ed2a280 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Fri, 28 Aug 2026 14:48:12 -0700 Subject: [PATCH 3/5] refactor: adopt smp's non-generic TypeIs narrowers Corrects a claim made in the two commits before this one. Those messages said smp's non-generic `TypeIs` narrowers "erode `TRep` for a generic caller", breaking the `ensure_request` helper in `examples/*/upgrade.py`, and that `tests/test_generics_typing.py` was the regression test proving it. That is false, and it is why the generic signatures were kept. The pyright error that produced the claim was a cascade from an unrelated broken import: at that moment the examples still did `from smpclient.generics import SMPRequest`, which had just moved to smp, so `SMPRequest` was an unknown symbol and `SMPRequest[TRep, TEr1, TEr2]` degraded to Unknown -- which is what made the narrowed value unassignable to `TRep`. Fixing the import fixed it; the narrowers were never involved. Verified by swapping the two forms in place and running the whole gate against each. Both are clean under mypy and pyright, `assert_type(response, EchoWriteResponse)` holds either way in `tests/test_generics_typing.py`, and the generic `ensure_request` helper type-checks under both. So take smp's form, which is the better one: the repository now has **zero** pyright warnings, down from five `reportInvalidTypeVarUse`. Those five were the reason #134 could not simply turn on `--warnings`; that is now unblocked, and the narrowers match the reference implementation in smp's own typing test. `TRep`/`TEr1`/`TEr2` stay -- `request()` still needs them for its signature -- they are just no longer threaded through the narrowers. Co-Authored-By: Claude Opus 5 (1M context) --- src/smpclient/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index 1ac3a33..a5ac7f6 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -41,7 +41,7 @@ from collections.abc import AsyncIterator from hashlib import sha256 from types import TracebackType -from typing import Final, TypeVar, Union +from typing import Any, Final, TypeVar, Union import msgspec from smp import SMPRequest @@ -74,7 +74,7 @@ """Type of successful SMP Response (ReadResponse or WriteResponse).""" -def error_v1(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV1]: +def error_v1(response: smpmsg.Response) -> TypeIs[smperror.ErrorV1]: """`TypeIs` that returns `True` if the `response` is an `ErrorV1`. Args: @@ -86,7 +86,7 @@ def error_v1(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V1 -def error_v2(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[TEr2]: +def error_v2(response: smpmsg.Response) -> TypeIs[smperror.ErrorV2[Any]]: """`TypeIs` that returns `True` if the `response` is an `ErrorV2`. Args: @@ -98,7 +98,9 @@ def error_v2(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[TEr2]: return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V2 -def error(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV1 | TEr2]: +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: @@ -110,7 +112,9 @@ def error(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV1 | return error_v1(response) or error_v2(response) -def success(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[TRep]: +def success( + response: smpmsg.Response, +) -> TypeIs[Union[smpmsg.ReadResponse, smpmsg.WriteResponse]]: """`TypeIs` that returns `True` if the `response` is a successful `Response`. Args: From 8432293fa34bb098687152c3b9360bc38e09f15b Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Fri, 28 Aug 2026 15:58:45 -0700 Subject: [PATCH 4/5] breaking: the client owns its sequence space (smp#71) Resync onto smp `screaming-goblin` at b9003299, which closed JPHutchins/smp#70: `SMPRequest.to_frame()` now declares `sequence`/`version`/`flags`, the module-global `itertools.count()` in `smp/message.py` is gone, and `sequence` is required and typed `u8`. `SMPClient` therefore owns one counter per connection instead of every client in the process sharing one. Sequences are now monotonic per client rather than interleaved with whatever else is running, and the 8-bit space is no longer consumed N times faster with N clients. The narrowing follows smp#71's migration note. `u8` is a `Literal[0..255]` alias from types-bits with no runtime constructor, so masking alone does not satisfy either checker -- `next(counter) % 0x100` is still an `int` to them. It is narrowed once, at the boundary: def _next_sequence(self) -> "u8": return cast("u8", next(self._counter) % 0x100) That is the only cast, and it buys the header's 8-bit field being enforced by the type checker rather than by a `struct.error` at pack time. Test and example call sites pass a literal, which needs no narrowing -- and reads better than the implicit global it replaces. `types_bits` is imported only under `TYPE_CHECKING`; it arrives as a declared dependency of smp, so nothing new is declared here. `camas matrix` green on 3.10-3.14; integration 229/229. Co-Authored-By: Claude Opus 5 (1M context) --- src/smpclient/__init__.py | 18 +++- tests/extensions/test_intercreate.py | 2 +- tests/integration/test_fragmentation.py | 18 ++-- tests/test_smp_client.py | 16 ++-- tests/test_smp_serial_raw_transport.py | 8 +- tests/test_smp_serial_transport.py | 2 +- uv.lock | 112 +++++++++++++----------- 7 files changed, 103 insertions(+), 73 deletions(-) diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index a5ac7f6..e7f873e 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -36,12 +36,13 @@ """ import asyncio +import itertools import logging import traceback from collections.abc import AsyncIterator from hashlib import sha256 from types import TracebackType -from typing import Any, Final, TypeVar, Union +from typing import TYPE_CHECKING, Any, Final, TypeVar, Union, cast import msgspec from smp import SMPRequest @@ -57,6 +58,9 @@ from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException from smpclient.transport import SMPTransport +if TYPE_CHECKING: + from types_bits import u8 + try: from asyncio import timeout # type: ignore except ImportError: # backport for Python3.10 and below @@ -211,6 +215,16 @@ def __init__(self, transport: SMPTransport, address: str, timeout_s: float = 2.5 self._transport: Final = transport self._address: Final = address self._timeout_s = timeout_s + self._counter: Final = itertools.count() + """This client's own SMP sequence space, one counter per connection.""" + + def _next_sequence(self) -> "u8": + """Take the next sequence from this client's own counter. + + `u8` is a `Literal[0..255]` alias with no runtime constructor, so a masked `int` + does not satisfy it; narrow once here rather than at every call site. + """ + return cast("u8", next(self._counter) % 0x100) async def connect(self, connect_timeout_s: float | None = None) -> None: """Connect to the SMP server. @@ -284,7 +298,7 @@ async def request( """ timeout_s = timeout_s if timeout_s is not None else self._timeout_s - request_frame: Final = request.to_frame() + request_frame: Final = request.to_frame(self._next_sequence()) try: async with timeout(timeout_s): diff --git a/tests/extensions/test_intercreate.py b/tests/extensions/test_intercreate.py index 3a9c596..00d7c16 100644 --- a/tests/extensions/test_intercreate.py +++ b/tests/extensions/test_intercreate.py @@ -42,7 +42,7 @@ def mock_write(data: bytes) -> int: 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(bytes(request.to_frame())) + 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 diff --git a/tests/integration/test_fragmentation.py b/tests/integration/test_fragmentation.py index 6c715c0..a028cc3 100644 --- a/tests/integration/test_fragmentation.py +++ b/tests/integration/test_fragmentation.py @@ -88,12 +88,18 @@ 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(bytes(EchoWriteRequest(d="").to_frame())) - 4) + 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())) <= transport.max_unencoded_size + assert len(bytes(request.to_frame(sequence=0))) <= transport.max_unencoded_size line_packets = len( - list(smppacket.encode(bytes(request.to_frame()), line_length=transport._line_length)) + 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: @@ -129,9 +135,9 @@ async def test_non_default_line_length(fixture: ServerFixture) -> None: text = "L" * 200 # > one 128-byte line packet, so the 512 line length is in effect request = EchoWriteRequest(d=text) - assert len(list(smppacket.encode(bytes(request.to_frame()), line_length=512))) < len( - list(smppacket.encode(bytes(request.to_frame()), line_length=128)) - ) + 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/test_smp_client.py b/tests/test_smp_client.py index 2dad448..1a0930e 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -148,7 +148,7 @@ async def test_request() -> None: s = SMPClient(m, "address") req = ResetWriteRequest() - m.receive.return_value = bytes(ResetWriteResponse().to_frame()) + m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) rep = await s.request(req) assert ResetWriteRequest.loads(sent_frame(m)).data == req m.receive.assert_awaited() @@ -159,7 +159,7 @@ async def test_request() -> None: assert error_v2(rep) is False # test that a bad sequence raises `SMPBadSequence` - m.receive.return_value = bytes(ResetWriteResponse().to_frame()) + m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) m.sequence_offset = 1 with pytest.raises(SMPBadSequence): await s.request(req) @@ -214,7 +214,7 @@ async def test_request_unparseable_frame() -> None: 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()) + 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) @@ -236,7 +236,7 @@ async def test_request_mismatched_group_propagates() -> None: m = SMPMockTransport() s = SMPClient(m, "address") - m.receive.return_value = bytes(ImageUploadWriteResponse(off=0).to_frame()) + m.receive.return_value = bytes(ImageUploadWriteResponse(off=0).to_frame(sequence=0)) with pytest.raises(SMPMismatchedGroupId): await s.request(ResetWriteRequest()) @@ -409,7 +409,7 @@ async def mock_request( ) -> 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(bytes(request.to_frame())) + 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 @@ -467,7 +467,7 @@ async def mock_request( ) -> 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(bytes(request.to_frame())) + 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 @@ -654,7 +654,7 @@ async def mock_request( ) -> 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(bytes(request.to_frame())) + 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 @@ -875,7 +875,7 @@ def test_maximize_upload_packet_fills_decoded_buffer( 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()) + frame = bytes(maximized.to_frame(sequence=0)) # the maximizer fills the decoded reassembly buffer exactly assert len(frame) == max_unencoded_size diff --git a/tests/test_smp_serial_raw_transport.py b/tests/test_smp_serial_raw_transport.py index 09c1bbe..5703efd 100644 --- a/tests/test_smp_serial_raw_transport.py +++ b/tests/test_smp_serial_raw_transport.py @@ -71,7 +71,7 @@ async def test_send() -> None: p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore - r = EchoWriteRequest(d="Hello pytest!").to_frame() + r = EchoWriteRequest(d="Hello pytest!").to_frame(sequence=0) await t.send(bytes(r)) # Raw transport writes the bytes verbatim - no encoding. @@ -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(bytes(EchoWriteRequest(d="x").to_frame())) + 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(bytes(EchoWriteRequest(d="x").to_frame())) + await t.send(bytes(EchoWriteRequest(d="x").to_frame(sequence=0))) @pytest.mark.asyncio @@ -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 = bytes(EchoWriteRequest(d="Hello pytest!").to_frame()) + 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" diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index c0e00d9..b12ce9b 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -83,7 +83,7 @@ async def test_send() -> None: p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore - r = EchoWriteRequest(d="Hello pytest!").to_frame() + 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() diff --git a/uv.lock b/uv.lock index 0f8036e..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 = [ @@ -942,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 = [ @@ -1143,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 = [ @@ -1958,7 +1958,7 @@ 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 = [ @@ -2218,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 = [ @@ -2230,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 = [ @@ -2631,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 = [ @@ -2649,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 = [ @@ -2668,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 = [ @@ -2727,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 = [ @@ -3121,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" } @@ -3291,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 = [ @@ -3319,13 +3319,14 @@ wheels = [ [[package]] name = "smp" -version = "4.1.1.dev11+g6bce38bc5" -source = { git = "https://github.com/JPHutchins/smp?rev=screaming-goblin#6bce38bc5e82279605faecbb00cb30f1e45f453f" } +version = "4.1.1.dev14+gb90032996" +source = { git = "https://github.com/JPHutchins/smp?rev=screaming-goblin#b90032996b1b7a595006d0ca04bf7fafc421fc80" } dependencies = [ { name = "cbor2" }, { name = "crcmod" }, { name = "msgspec" }, { name = "msgspec-cbor" }, + { name = "types-bits" }, ] [[package]] @@ -3464,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 = [ @@ -3478,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" } @@ -3584,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" @@ -3665,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 = [ @@ -3813,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 = [ @@ -3839,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 = [ @@ -3865,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 = [ @@ -3891,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 = [ @@ -3917,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 = [ @@ -3943,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 = [ @@ -3969,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 = [ @@ -3995,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 = [ @@ -4021,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 = [ @@ -4047,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 = [ From 1a92ac07f09df18943f6a6096994fd7ebbef489a Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Fri, 28 Aug 2026 16:20:02 -0700 Subject: [PATCH 5/5] refactor: inject the sequence space; bare `u8` without a runtime dependency Both review comments on #137. ## The sequence space is a constructor parameter SMPClient(transport, address, sequence=iter((7, 9))) defaulting to `wrapping_sequence()`, the 0x00-0xFF space the client used to hold privately. A test can now pin exactly what goes on the wire instead of inferring it, and resetting or sharing a sequence space becomes a caller's decision rather than something to add later. `_next_sequence()` is gone -- `request()` just takes `next(self._sequence)`. Covered by `test_injected_sequence`, which asserts the injected values reach the header, and `test_wrapping_sequence`, which pins the default's range and wrap. ## `u8`, not `"u8"` The quotes were there because `types_bits` was imported under `TYPE_CHECKING`. Importing it at runtime instead removes them, but breaks a clean install: File ".../smpclient/__init__.py", line 56, in from types_bits import u8 File ".../types_bits/__init__.py", line 20, in __getattr__ from annotated_types import Ge, Le ModuleNotFoundError: No module named 'annotated_types' `types-bits` declares `annotated-types` only under an `rt` extra, and says so itself: "`__init__.pyi` shadows this module for type checkers; this is the runtime tier." It is a typing-only package by design, so making every smpclient install carry `annotated_types` to unquote an annotation is the wrong trade. Instead the name is given a runtime value only checkers ignore: if TYPE_CHECKING: from types_bits import u8 else: u8 = int Every use is now bare -- the annotations and the one `cast` -- with no runtime dependency and no new declaration. Verified against a clean `uv pip install .`, which is the check that caught the problem. `camas matrix` green on 3.10-3.14; integration 229/229. Co-Authored-By: Claude Opus 5 (1M context) --- src/smpclient/__init__.py | 31 ++++++++++++++++++------------- tests/test_smp_client.py | 22 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index e7f873e..1d81e14 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -39,7 +39,7 @@ 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 TYPE_CHECKING, Any, Final, TypeVar, Union, cast @@ -60,6 +60,8 @@ 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 @@ -139,6 +141,11 @@ def success( """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.""" @@ -189,6 +196,7 @@ 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 @@ -211,20 +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._counter: Final = itertools.count() - """This client's own SMP sequence space, one counter per connection.""" - - def _next_sequence(self) -> "u8": - """Take the next sequence from this client's own counter. - - `u8` is a `Literal[0..255]` alias with no runtime constructor, so a masked `int` - does not satisfy it; narrow once here rather than at every call site. - """ - return cast("u8", next(self._counter) % 0x100) + 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. @@ -298,7 +303,7 @@ async def request( """ timeout_s = timeout_s if timeout_s is not None else self._timeout_s - request_frame: Final = request.to_frame(self._next_sequence()) + request_frame: Final = request.to_frame(next(self._sequence)) try: async with timeout(timeout_s): diff --git a/tests/test_smp_client.py b/tests/test_smp_client.py index 1a0930e..0570a3e 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -38,7 +38,7 @@ ResetWriteResponse, ) -from smpclient import SMPClient, error, error_v1, error_v2, success +from smpclient import SMPClient, error, error_v1, error_v2, success, wrapping_sequence from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException from smpclient.transport.serial import ( BufferParams, @@ -226,6 +226,26 @@ 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.