Skip to content

breaking: screaming-goblin - #143

Draft
JPHutchins wants to merge 8 commits into
mainfrom
screaming-goblin
Draft

JPHutchins wants to merge 8 commits into
mainfrom
screaming-goblin

Conversation

@JPHutchins

@JPHutchins JPHutchins commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Warning

LLM Disclosure

This PR was opened by claude-opus-5-5[1m] on behalf of @JPHutchins, who asked for a draft release PR from the screaming-goblin breaking-release trunk into main, mirroring JPHutchins/smp#66 and linked to the epic.

Caution

This is a BREAKING release. It ports smpclient onto smp's breaking Frame[T]/msgspec message API and reworks the client/transport lifecycle. smp releases first and smpmgr follows in lockstep. Merging this to main is the breaking version bump.

What this is

screaming-goblin is smpclient's breaking-release integration trunk for the transport-subcommands epic intercreate/smpmgr#103. Breaking work lands here, and non-breaking fixes land on main and merge forward. This draft is the staging point for the release into main. Keep it in draft until the breaking set is complete and smp has released.

Upstream counterpart: JPHutchins/smp#66 (smp's screaming-goblin).

Included so far

In progress

Landed on main and merged forward

Before this leaves draft

Part of the epic: intercreate/smpmgr#103

🤖 Generated with Claude Code

JPHutchins and others added 8 commits August 28, 2026 14:16
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>
…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>
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>
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>
…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>
breaking: port to smp screaming-goblin (Frame[T] on msgspec)
#137 bound `u8 = int` in an `else` branch of the `TYPE_CHECKING` import, so
the checkers saw `types_bits.u8` (`Literal[0..255]`) while the interpreter saw
`int`: an invisible divergence. The branch existed only because two expressions
evaluated `u8` at runtime: the `sequence: Iterator[u8] | None` annotation on
`SMPClient.__init__` (evaluated eagerly on 3.10-3.13) and the first argument of
`cast()` in `wrapping_sequence()`.

With `from __future__ import annotations`, annotations are stored as strings
and never evaluated. `cast()`'s first argument is an ordinary expression, not an
annotation, so it is quoted. Nothing binds `u8` at runtime any more, so the
`else` branch and its explanatory comment go together.

Why not declare `types-bits[rt]` instead: smp itself declares
`types-bits<0.3,>=0.2` without the `rt` extra, so the library that defines `u8`
treats it as typing-only. The extra only pulls in `annotated-types` (one of
pydantic's dependencies, which #136 evicted), and nothing in smpclient
validates a `u8` at runtime: `cast()` is a no-op, and the annotation is never
read.

Verified: `camas check` and `camas matrix` (3.10-3.14) green. A clean
`uv pip install .` into a fresh 3.10 venv (what the `transport-extras` CI job
does) has no `annotated_types`; `import smpclient` succeeds and
`wrapping_sequence()` wraps 255 -> 0, with `u8` unbound at runtime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
refactor: drop the runtime `u8 = int` shim; `u8` is typing-only

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove the generic request/response pattern

1 participant