Skip to content

build: lock pyright alongside mypy; camas 0.1.18 -> 0.1.29 - #127

Merged
JPHutchins merged 2 commits into
mainfrom
build/pyright-and-camas
Aug 22, 2026
Merged

build: lock pyright alongside mypy; camas 0.1.18 -> 0.1.29#127
JPHutchins merged 2 commits into
mainfrom
build/pyright-and-camas

Conversation

@JPHutchins

Copy link
Copy Markdown
Collaborator

Warning

LLM Disclosure

This PR was authored by claude-opus-5[1m] on behalf of @JPHutchins, who asked to begin the smpclient phase of the screaming-goblin breaking 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 on main first 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 bumps camas[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 over src, tests, examples.

Everything below was found by pyright on code mypy already passed. No behavior changes.

Why now

The screaming-goblin port to smp's Frame[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 on main.

The findings

1. @override must be applied inside @property (7 sites, 5 transports)

Every transport property was decorated:

@override
@property
def mtu(self) -> int: ...

which applies override to the property object rather than to the function. mypy accepts it; pyright reports reportArgumentType. Verified empirically that @property outermost is accepted by both checkers and correct at runtime, then swapped all 7.

2. Explicit ... bodies on Protocol stubs (10 sites, 2 files)

SMPTransport and SerialFraming methods had docstring-only bodies. Where the 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, 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/memoryviewbytes; pyright does not, and the typing spec has deprecated the promotion.

Setting 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. That is the promotion being unsound, so it stays off and the four boundaries are stated accurately instead:

Site Change
SMPBLETransport._notify_callback takes bytearray — that is what bleak passes it
SMPBLETransport.receive Header.loads(bytes(...)) — an 8-byte copy of the sliced receive buffer
SMPBumbleTransport.receive same
SMPUDPTransport.receive returns bytes(message) instead of returning its bytearray accumulator from a -> bytes signature
4. bleak 3 specifics (4 sites)
  • BleakGATTCharacteristic is no longer re-exported from the bleak top level (reportPrivateImportUsage) — imported from bleak.backends.characteristic, in the transport and in its test.

  • The backend predicates were typed too narrowly. _bluez_backend/_winrt_backend took BaseBleakClient, but off-platform one of BleakClientBlueZDBus/BleakClientWinRT is a local Protocol stub. The and-guarded branch leaves pyright holding BaseBleakClient | BleakClientWinRTcorrectly, since a false if can mean "was WinRT, but MTU != 20":

    if (
        self._winrt_backend(self._client._backend)
        and self._max_write_without_response_size == 20
    ):
        ...
    elif self._bluez_backend(self._client._backend):  # <-- union arrives here

    Both predicates only read __class__.__name__, so they now take a _ClientBackend alias covering the real backend plus both stubs.

  • The Windows MTU workaround is legitimate; only its declared type disagrees. It assigns an int to BleakGATTCharacteristic._max_write_without_response_size, which bleak declares Callable[[], int]. bleak's own getter explicitly supports this:

    # for backwards compatibility
    if isinstance(self._max_write_without_response_size, int):
        return self._max_write_without_response_size
    
    return self._max_write_without_response_size()

    bleak/backends/characteristic.py

    Behavior unchanged; the type error is suppressed at that one line.

Verification

camas matrix green across Python 3.10–3.14 — 5/5, each running ruff check, pydoclint, mypy, pyright, and the full non-integration test suite.

✓ uv run --python 3.10 camas check      ✓ uv run --python 3.13 camas check
✓ uv run --python 3.11 camas check      ✓ uv run --python 3.14 camas check
✓ uv run --python 3.12 camas check

Known residuals (deliberate, not oversights)

  • pyright warnings do not gate. generics.py emits 8 reportInvalidTypeVarUse warnings (Protocol TypeVars that should be covariant). 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. Turning on --warnings is the natural lock-tightening follow-up once the port lands.
  • CI has no format_check leaf. github_task=check never verifies formatting (ruff check catches import order via I, not formatting); only the mutating all task formats. smp's check includes format_check. Out of scope here — worth a follow-up.

Issues

Part of the epic intercreate/smpmgr#103. Prerequisite infrastructure for the smpclient screaming-goblin port (which will close #124).

🤖 Generated with Claude Code

JPHutchins and others added 2 commits August 21, 2026 16:30
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 JPHutchins left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@JPHutchins
JPHutchins merged commit c4b7df6 into main Aug 22, 2026
29 checks passed
@JPHutchins
JPHutchins deleted the build/pyright-and-camas branch August 22, 2026 01:34
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>
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