breaking: port to smp screaming-goblin (Frame[T] on msgspec) - #137
Conversation
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) <noreply@anthropic.com>
f31a7e6 to
368bcbe
Compare
JPHutchins
left a comment
There was a problem hiding this comment.
Pretty good. Can we make sure we have solid assert_never coverage for generic typing? E.g. our request function.
Should we consider leaving the old requests namespace alive, including the shortened names, and just have a deprecation warning (they would re-export the smp types)?
We should link to the smp docs from request.md I think.
There was a problem hiding this comment.
We can drop generics and move this simple stuff into top level
There was a problem hiding this comment.
Done in 1759213 — smpclient/generics.py is deleted and the four narrowers plus TRep/TEr1/TEr2 now live in smpclient/__init__.py, next to the request() whose return type they narrow.
Agreed it no longer earned a module: the one part that justified the separation was the SMPRequest Protocol, and that moved to smp.
Import sites become from smpclient import success, error, ... across src/, tests/, and examples/.
| "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", |
There was a problem hiding this comment.
huh? Isn't it transitive?
There was a problem hiding this comment.
It was transitive — that's precisely why it broke.
typing_extensions was arriving via pydantic, via smp 4.x. This PR pins smp to screaming-goblin, which dropped pydantic, so it left the dependency tree with it. CI caught it:
File ".../smpclient/__init__.py", line 54, in <module>
from typing_extensions import assert_never
ModuleNotFoundError: No module named 'typing_extensions'
All six transport-extras jobs failed on it, and nothing else did — because that job runs uv venv && uv pip install ., which resolves only declared dependencies, while every other job installs from the lockfile (where the dev group happens to pull typing_extensions in anyway). It's the one job that can catch this class of bug.
It's a genuine runtime dependency across the whole supported range, used by 12 modules in src/:
genericsneedsTypeIs— stdlib only from 3.13, and we support 3.10+- the transports need
override(stdlib 3.12+) andassert_never(stdlib 3.11+)
Hence >=4.10, which is where TypeIs landed.
Worth noting this is the third undeclared-transitive dependency in this effort — pydantic (#133), then msgspec, now this. Each was invisible while something else happened to pull it in.
…s 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) <noreply@anthropic.com>
|
Warning LLM Disclosure This comment was posted by Pushed 1759213. Three of the four are done; the fourth is a real decision I'd rather put back to you with the one fact that changes it. ✅
|
| Signal | mypy | pyright |
|---|---|---|
@deprecated on a class |
errors (with --enable-error-code=deprecated) |
errors (with reportDeprecated) |
plain alias (X = smp.Y) |
nothing | nothing |
So the shim would give a runtime DeprecationWarning via module __getattr__ and nothing else. For smpmgr — typed, and the main consumer — that's the weaker half: the checker won't point at the call sites, so the migration stays manual either way. You'd just find out when the code runs instead of when it imports.
My recommendation: keep the hard break, since the type checker can't help either way and the shortened names are the SSOT problem #124 is removing. The thing that would genuinely help external users is a migration table in the release notes — 33 symbols, mechanical — which I can write.
That said, this is a public package with outside contributors, and ModuleNotFoundError: smpclient.requests.os_management with no pointer is a poor landing. If you'd rather have the softer version, say so and I'll add the alias module with the runtime warning; it's small.
Note on smp#70
Understood that the smp changes land before this merges. When JPHutchins/smp#70 is in, I'll resync and switch request() to a client-owned sequence counter — which is what that issue exists to unblock.
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) <noreply@anthropic.com>
|
Warning LLM Disclosure This comment was posted by Correcting myself, in answer to "does this need an upstream smp fix?" — no, and my reason for not using smp's narrowers was wrong. I claimed above that smp's non-generic That is false. The pyright error I based it on: was a cascade from an unrelated broken import in the same edit — the examples still did I verified this properly by swapping the two forms in place and running the whole gate against each:
So smp's form is strictly better and is now adopted in No upstream smp change is needed for any of this. The one upstream item that does remain is JPHutchins/smp#70 ( |
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) <noreply@anthropic.com>
JPHutchins
left a comment
There was a problem hiding this comment.
Some changes needed
| self._counter: Final = itertools.count() | ||
| """This client's own SMP sequence space, one counter per connection.""" | ||
|
|
||
| def _next_sequence(self) -> "u8": |
There was a problem hiding this comment.
Why "u8" - never necessary
There was a problem hiding this comment.
Fixed in 1a92ac0 — every use is bare u8 now, annotations and the one cast.
The quotes were there because types_bits was imported under TYPE_CHECKING. Importing it at runtime does remove them, but it breaks a clean install:
File ".../smpclient/__init__.py", line 56, in <module>
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 its own module docstring says why: "__init__.pyi shadows this module for type checkers; this is the runtime tier." It's typing-only by design — so making every smpclient install carry annotated_types just to unquote an annotation is a bad trade.
So the name gets a runtime value that checkers never see:
if TYPE_CHECKING:
from types_bits import u8
else: # `types_bits` is typing-only; reaching its values at runtime needs its `rt` extra
u8 = intBare everywhere, no runtime dependency, nothing new declared. Caught and verified with a clean uv pip install . — the same check that found the typing_extensions gap.
| @@ -152,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) | |||
There was a problem hiding this comment.
User should have to provide an Iterator[u8] that defaults to the default one you made. Will make testing easier and support future direction for resetting sequence etc.
There was a problem hiding this comment.
Done in 1a92ac0.
SMPClient(transport, address, sequence=iter((7, 9)))defaulting to wrapping_sequence() — the 0x00–0xFF space the client used to hold privately. _next_sequence() is gone; request() just takes next(self._sequence).
Two tests came with it:
test_injected_sequence— asserts the injected values actually reach the header on the wire, rather than inferring themtest_wrapping_sequence— pins the default's range and its wrap at 0x100
Agreed on the future direction too: resetting or sharing a sequence space is now a caller's decision rather than something to retrofit.
…ndency 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 <module> 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) <noreply@anthropic.com>
Warning
LLM Disclosure
This PR was authored by
claude-opus-5[1m]on behalf of @JPHutchins, who asked me to continue thescreaming-goblineffort autonomously after #136 merged, treating their CLAUDE.md as law.Note
Targets
screaming-goblin, the breaking branch, per the epic's rule (intercreate/smpmgr#103). Closes #124.smp's
screaming-goblinreplaces the flattened pydantic message model with composition — a message is aFrame[T] = (Header, Data[T])onmsgspec.Struct— and bakes each request's_Response/_ErrorV1/_ErrorV2binding into the request class. This ports smpclient onto it.Net −872 lines (576 insertions, 1448 deletions; 20 files deleted).
The request layer is gone (#124)
smpclient/requests/**existed only to attach the response/error binding to each smp request. smp does that itself now, so the layer is deleted outright rather than deprecated —screaming-goblinis the breaking release. Callers move to smp's own names, which differ:CountSupportedGroupssmp.enumeration_management.GroupCountRequestImageStatesReadsmp.image_management.ImageStatesReadRequestEchoWritesmp.os_management.EchoWriteRequest25 files migrated here; smpmgr will need the same.
The narrowers stay (but come from smp's design)
smp deliberately does not ship
success/error/error_v1/error_v2— its own typing test says so outright ("without thesmplibrary having to ship them") — so they stay insmpclient.SMPRequestnow comes fromsmp.They use smp's non-generic
TypeIsform.TRep/TEr1/TEr2remain forrequest()'s own signature, just not threaded through the narrowers. The repository now has zero pyright warnings, down from fivereportInvalidTypeVarUse— which unblocks turning on--warningsin #134.request()The frame is built once, because it carries the sequence the response must echo:
loads()returns aFrame, so the client returns.data.The decode chain catches
DecodeError, notValidationErrorBoth are reachable, and the wider catch is deliberate — measured:
ValidationErrorDecodeErrorDecodeErrorCatching only
ValidationErrorwould let a truncated response escape as a raw msgspec traceback instead of theSMPValidationExceptioncarrying the header and hexdump. Under smp 4.x a raw cbor2 error escaped uncaught, so this is also a small improvement.SMPMalformedandSMPMismatchedGroupIdare deliberately not caught. They fail all three candidate types identically, which makes them transport errors rather than "this frame matched no schema" — swallowing them would report a group mismatch as three parse failures. Both cases now have tests.Size math
bytes(Data)is the CBOR payload alone — the oldbytes(message)included the 8-byte header — soget_max_cbor_and_data_sizesubtractsHeader.SIZEexplicitly._maximize_upload_packetcollapses to one call, sinceto_frame()computeslengthfrom the actual payload:_ic_maximize_packetwas a hand-rolled copy of that logic, needed only because pydantic made field-carrying manual. It is deleted andICUploadClientuses the generic one.Tests
SMPMockTransportnow 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-builtsmphdr.Header(...)blocks and every(h.sequence + 2) % 0xFFcomputation simply disappear. Asequence_offsetfakes a server answering out of order for theSMPBadSequencecase.ErrorV1/ErrorV2declare no_OP/_COMMAND_ID, soto_frame()cannot synthesize their header; a test that needs their bytes builds it by hand, exactly as smp's owntest_error.pydoes. That iserror_bytes().Docs
Deleting the request layer removes the only reason
docs/_generate_requests_docstrings.pyexisted — it back-filled inherited docstrings onto those subclasses. The script, its step in bothrelease.yamlandtest-docs.yaml, the seven per-group pages, and their nav entries all go.docs/requests.mdstays (the narrowers it documents stay), anddocs/user/intercreate.mdnow points atsmpclient.extensions.intercreate, which is genuinely smpclient's.Dependency
smpis pinned to the branch by direct reference (hatchling needsallow-direct-references), since smp is not released with the break — both marked TODO to undo at release.msgspecis now declared: this module imports it directly forDecodeErrorandstructs.replace, and relying on it arriving through smp would repeat the undeclared-transitive mistake #133 just fixed for pydantic.typing_extensionsis now declared too, and the way it surfaced is worth recording: it is imported at runtime by twelve modules —genericsneedsTypeIs, the transports needoverride/assert_never— but was never declared. It had been arriving transitively via pydantic, via smp 4.x, so dropping pydantic from the tree took it with it.The
transport-extrasCI job caught it and the others could not: it runsuv venv && uv pip install ., which resolves only declared dependencies, while every other job installs from the lockfile (where the dev group happens to supply it). That job is the only guard against this class of bug — the third undeclared-transitive dependency in this effort, after pydantic (#133) and msgspec.With #133,
src/no longer imports pydantic at all.Verification
camas matrixgreen on Python 3.10–3.14On the one integration flake seen during this work
test_upload_to_mcuboot_recovery[mps2_an385.serial_recovery_raw-raw]— the flake #128 addressed — recurred. I checked it was not a regression by running it on both trees under identical load:mainSame rate, so the port does not cause it. Worth noting separately though: #128's pacing is evidently not sufficient under heavy machine load — it measured 40/40 last week and ~10–20% failures today with qemu suites running back to back. That belongs with #131/#132 rather than here.
Follow-ups filed
SMPRequest.to_frame()hidessequence/version/flags, so a client cannot own its sequence space and is forced onto smp's module-global counter. Also blocks Convenience methods should expose SMP version kwarg #43/feat(client): expose the SMP version on the convenience methods #126.Conflicts to expect
Open PRs #123 (upload retries) and #126 (SMP version kwarg) both touch code this rewrites; whichever lands second will need rework.