build: lock pyright alongside mypy; camas 0.1.18 -> 0.1.29 - #127
Merged
Conversation
Add pyright as a second, gating type checker so `typecheck` is `Parallel(mypy, pyright)`. Two checkers disagree in useful ways: everything below was found by pyright on code mypy already passed, and `camas matrix` is green on Python 3.10-3.14 with both. Mirrors the configuration smp adopted in JPHutchins/smp#67, so the editor (Pylance) and the camas CLI share one `[tool.pyright]` (standard mode over `src`, `tests`, `examples`). Groundwork for the `screaming-goblin` port to smp's Frame[T]/msgspec API (intercreate/smpmgr#103): that port rewrites the request path wholesale, and it is far cheaper to have both checkers gating before it starts than to retrofit one after. ## @OverRide must be inside @Property 7 properties across 5 transports were decorated @OverRide @Property def mtu(self) -> int: ... which applies `override` to the `property` object rather than to the function. mypy accepts it; pyright rejects it (`reportArgumentType`). Swapped to `@property` outermost, verified accepted by both checkers and correct at runtime. ## Explicit `...` bodies on Protocol stubs `SMPTransport` and `SerialFraming` methods had docstring-only bodies. For a stub whose declared return type is not None-compatible, pyright reports `reportReturnType` — "must return value on all code paths". Added the explicit `...` body already used by `smpclient.generics`, and applied it uniformly rather than only where the return type happened to trip the check. ## bytes/bytearray boundaries are now honest mypy implicitly promotes `bytearray`/`memoryview` to `bytes`; pyright does not, and the typing spec has deprecated the promotion. Enabling `disableBytesTypePromotions = false` to paper over the difference was tried and rejected: it makes `case bytes()` look exhaustive when at runtime it does not match a `bytearray`, and it duly broke the `assert_never` in `SMPBumbleTransport._next_chunk`. So the promotion stays off and the four boundaries are stated accurately instead: - `SMPBLETransport._notify_callback` takes `bytearray` — that is what bleak passes it. - `Header.loads()` gets `bytes(...)` at the two callers that sliced a `bytearray` receive buffer (BLE, Bumble); an 8-byte copy. - `SMPUDPTransport.receive()` returns `bytes(message)` rather than returning its `bytearray` accumulator from a `-> bytes` signature. ## bleak 3 specifics - `BleakGATTCharacteristic` is no longer re-exported from the `bleak` top level (`reportPrivateImportUsage`); import it from `bleak.backends.characteristic`, in the transport and in its test. - `_bluez_backend`/`_winrt_backend` took `BaseBleakClient`, but off-platform one of `BleakClientBlueZDBus`/`BleakClientWinRT` is a local `Protocol` stub, and the `and`-guarded branch above the `elif` leaves pyright holding `BaseBleakClient | BleakClientWinRT` — correctly, since a false `if` can mean "was WinRT, but MTU != 20". Both predicates only read `__class__.__name__`, so they now take a `_ClientBackend` alias covering the real backend and both stubs. - The Windows MTU workaround assigns an int to `BleakGATTCharacteristic._max_write_without_response_size`, which bleak declares `Callable[[], int]`. The assignment is deliberate and supported: bleak's own getter does `if isinstance(self._max_write_without_response_size, int): return ...` "for backwards compatibility". Behavior unchanged; the type error is suppressed at that line. `generics.py` still emits 8 `reportInvalidTypeVarUse` warnings (Protocol TypeVars that should be covariant). Warnings do not gate, and that file is reshaped by the smp port — smp's `SMPRequest` already has the correct covariance — so they are left for the port rather than fixed and discarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`camas mcp init --claude` on 0.1.29. The only tracked change is `.mcp.json` dropping `--rich`, which 0.1.29 still accepts but now only for back-compat (rich output is the default; `--plain` is the opt-out). The rest of what init wrote is untracked -- `.claude/` is gitignored -- but is the reason to run it, and one of those changes fixes a hazard this session hit for real: - The `PostToolBatch` autofix hook becomes `uv run camas mcp fix || exit 0`. Without the `|| exit 0`, any non-zero exit from the hook stops the agent's tool continuation. That is not hypothetical: checking out a branch based on `main` reverted `pyproject.toml` to the old `camas==0.1.14` pin, `uv run` re-synced the venv down to it, and 0.1.14 has no `camas mcp fix` subcommand -- so the hook failed to parse its arguments and blocked the session until the venv was resynced. The guard makes the hook advisory instead of fatal. - Adds the `Stop` hooks 0.1.29 expects: a second autofix pass, plus an async `camas mcp gate --under 5s --nudge` rewake. - Adds the tiered `camas-lint-fixer-haiku` / `camas-lint-fixer-sonnet` / `camas-test-fixer` agents and the `gate` skill. Verified: `camas mcp fix --paths ...` and `camas mcp gate --under 5s --paths ...` both exit 0, and `camas_check` reports the 12-task definition valid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JPHutchins
added a commit
that referenced
this pull request
Aug 22, 2026
…rrun Fixes the ~3% flake in `test_upload_to_mcuboot_recovery[mps2_an385 .serial_recovery_raw-raw]`, which failed CI on an unrelated PR (#127) and reproduced locally at 1-in-30. ## What was happening The raw transport writes a 1024 B SMP chunk as a single burst. While the server is still busy flashing the *previous* chunk, its UART RX pool overruns and the overflow is silently dropped. Nothing recovers from that: the raw protocol is `[8-byte header][header.length bytes]` with no delimiter and no CRC, so the server waits forever for a message whose tail it never received, and the client burns its whole 15 s request timeout. That predicts a bimodal latency distribution, and measuring one confirms it. Worst single request per run, across 9 runs: run 1..7 PASS worst_request_s = 0.276 .. 0.309 run 9 FAIL worst_request_s = 15.001 There is nothing in between. A 50x gap with no tail means this is not "qemu is slow" -- raising the timeout would only make the test hang longer before failing the same way. `ServerFixture.bursty_fragment_drop` already documents this mechanism for native_sim PTY serial ("no baud pacing, so a >2-fragment message written all at once is dropped"). Its claim that "Emulated (socket) and UDP fixtures are unaffected" is what is wrong: mps2_an385 is affected too, just rarely, because it also needs the server to be mid-flash-write. ## The fix, and why it is scoped to the raw transport A real UART paces the client -- bytes leave at the baud rate, so the server's RX pool drains about as fast as it fills. A socket chardev has no pacing at all, so `_PacedSocketChardev` supplies it, splitting each write and spacing the pieces by wall-clock time. Pacing every socket chardev is wrong, and measurably so. The first version of this change put the pacing in the shared `_connect_socket_chardev`, which also affected `SMPSerialTransport`; that traded one flake for another, destabilising `qemu_cortex_m0.serial_buf256` (2/15 failures, against 0/15 unpaced) -- a 16 KB target that `max_reliable_line_packets` already flags as fragile once a transaction stays open too long. The encoded transport writes one small base64 line packet at a time, which paces it well enough on its own. So the chardev class is now a parameter of `_connect_socket_chardev` and the caller names it: only `QemuSocketSerialRawTransport` binds the paced one. The encoded transport is unaffected by construction rather than by exclusion. The pause must be wall-clock. Measured, 40 runs each: unpaced 1/30, then 2/40 failures 64 B chunks + asyncio.sleep(0) 3/40 failures (no better) 64 B chunks + 1 ms sleep 0/40 failures An event-loop yield does nothing here: the guest needs real time on a real CPU, not a turn of the event loop. The chunk size is incidental; the interleaved delay is the whole fix. Splitting the chardev into `_SocketChardev` and `_PacedSocketChardev` also retires the `object.__setattr__(conn, "out_waiting", 0)` monkeypatch in favour of a plain, type-checked class attribute. `src/` is untouched. `write_timeout` also had to become non-zero: pyserial reads a zero write timeout as "non-blocking" and its socket `write()` then issues one `socket.send()` and returns that count without looping, silently dropping any remainder. That is a real latent hazard -- `send()` discards `write()`'s return value -- but it is not this flake: fixing it alone still failed 2/40. Here it is simply required for `super().write()` to put the whole chunk out. ## Verification - The original flake: 25/25, from 1-in-30. - The regression the first version caused: 25/25, from 2-in-15. - Full integration suite: 229 passed, 101 skipped, 0 failures. - mypy and pyright both clean on the changed file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
LLM Disclosure
This PR was authored by
claude-opus-5[1m]on behalf of @JPHutchins, who asked to begin the smpclient phase of thescreaming-goblinbreaking effort (intercreate/smpmgr#103) — audit the outstanding issues, and as a nice-to-have "lock pyright type checking if we don't have it already". This is the non-breaking infrastructure step, landed onmainfirst so the smp port is gated by both type checkers from its first line rather than retrofitted afterward.What
Adds pyright as a second, gating type checker (
typecheck = Parallel(mypy, pyright)) and bumpscamas[mcp]0.1.18 → 0.1.29.Mirrors the configuration smp adopted in JPHutchins/smp#67, so the editor (Pylance) and the camas CLI share one
[tool.pyright]— standard mode oversrc,tests,examples.Everything below was found by pyright on code mypy already passed. No behavior changes.
Why now
The
screaming-goblinport to smp'sFrame[T]/msgspec API rewrites the request path wholesale. Having both checkers gating before that starts is much cheaper than retrofitting one after — and this half is non-breaking, so per the epic's own branch rule it belongs onmain.The findings
1.
@overridemust be applied inside@property(7 sites, 5 transports)Every transport property was decorated:
which applies
overrideto thepropertyobject rather than to the function. mypy accepts it; pyright reportsreportArgumentType. Verified empirically that@propertyoutermost is accepted by both checkers and correct at runtime, then swapped all 7.2. Explicit
...bodies on Protocol stubs (10 sites, 2 files)SMPTransportandSerialFramingmethods had docstring-only bodies. Where the declared return type is notNone-compatible, pyright reportsreportReturnType— "must return value on all code paths".Added the explicit
...body already used bysmpclient.generics, applied uniformly rather than only where the return type happened to trip the check.3. bytes/bytearray boundaries are now honest (4 sites)
mypy implicitly promotes
bytearray/memoryview→bytes; pyright does not, and the typing spec has deprecated the promotion.Setting
disableBytesTypePromotions = falseto paper over the difference was tried and rejected: it makescase bytes()look exhaustive when at runtime it does not match abytearray, and it duly broke theassert_neverinSMPBumbleTransport._next_chunk. That is the promotion being unsound, so it stays off and the four boundaries are stated accurately instead:SMPBLETransport._notify_callbackbytearray— that is what bleak passes itSMPBLETransport.receiveHeader.loads(bytes(...))— an 8-byte copy of the sliced receive bufferSMPBumbleTransport.receiveSMPUDPTransport.receivebytes(message)instead of returning itsbytearrayaccumulator from a-> bytessignature4. bleak 3 specifics (4 sites)
BleakGATTCharacteristicis no longer re-exported from thebleaktop level (reportPrivateImportUsage) — imported frombleak.backends.characteristic, in the transport and in its test.The backend predicates were typed too narrowly.
_bluez_backend/_winrt_backendtookBaseBleakClient, but off-platform one ofBleakClientBlueZDBus/BleakClientWinRTis a localProtocolstub. Theand-guarded branch leaves pyright holdingBaseBleakClient | BleakClientWinRT— correctly, since a falseifcan mean "was WinRT, but MTU != 20":Both predicates only read
__class__.__name__, so they now take a_ClientBackendalias covering the real backend plus both stubs.The Windows MTU workaround is legitimate; only its declared type disagrees. It assigns an
inttoBleakGATTCharacteristic._max_write_without_response_size, which bleak declaresCallable[[], int]. bleak's own getter explicitly supports this:—
bleak/backends/characteristic.pyBehavior unchanged; the type error is suppressed at that one line.
Verification
camas matrixgreen across Python 3.10–3.14 — 5/5, each runningruff check,pydoclint,mypy,pyright, and the full non-integration test suite.Known residuals (deliberate, not oversights)
generics.pyemits 8reportInvalidTypeVarUsewarnings (Protocol TypeVars that should be covariant). That file is reshaped by the smp port — smp'sSMPRequestalready has the correct covariance — so they are left for the port rather than fixed and discarded. Turning on--warningsis the natural lock-tightening follow-up once the port lands.format_checkleaf.github_task=checknever verifies formatting (ruff checkcatches import order viaI, not formatting); only the mutatingalltask formats. smp'scheckincludesformat_check. Out of scope here — worth a follow-up.Issues
Part of the epic intercreate/smpmgr#103. Prerequisite infrastructure for the smpclient
screaming-goblinport (which will close #124).🤖 Generated with Claude Code