Skip to content

Fix five correctness bugs in the Python wrapper - #1

Merged
gotwalt merged 1 commit into
mainfrom
python-review-fixes
Aug 21, 2026
Merged

Fix five correctness bugs in the Python wrapper#1
gotwalt merged 1 commit into
mainfrom
python-review-fixes

Conversation

@gotwalt

@gotwalt gotwalt commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Review of the hand-written Python wrapper around src/libkp/_generated.py (the generated module itself is out of scope). Five real bugs, each with a regression test that fails before the fix.

Fixed

Terminal layout — both examples render wider than they claim

examples/meters.py::_default_width() returned columns - LABEL_WIDTH - 26, but a meter row spends LABEL_WIDTH + 31 columns on chrome (indent, label, brackets, value, range … readout). Every row came out six columns too wide, so on any terminal from ~57 to ~110 columns every meter row wrapped and the full-screen frame — drawn with HOME + per-line CLEAR_EOL — tore apart. Measured before the fix: an 80-column terminal produced 85-column rows. The magic 26 is now a derived ROW_CHROME constant.

examples/meters_tui.py::_bar_width() had the same class of bug with self.size.width - 34. Rich crops an over-wide grid cell with an ellipsis instead of wrapping, so the right-hand end of every bar — including the peak-hold marker — was silently chopped at 80 and 100 columns.

The TUI meters table hard-coded width=16 for its label column, but the --all labels reach 18 characters ("v2 strobe seg high"), so three of the eleven raw rows wrapped onto a second line with the bar misaligned below the label. The column width is now derived from the widest label the table can show.

nrpn.sysex() silently retargeted out-of-range addresses

It masked every header byte with & 0x7F. DeviceModel.set_param is documented as "the escape hatch for any address", so set_param(0x80, 4, value) built a perfectly valid message aimed at page 0 — the string/morph page — rather than surfacing the mistake. It now raises ValueError, consistent with protocol.push_field. (Rust/Swift take u8/UInt8 and emit the raw byte, producing a corrupt SysEx; raising is better than either.) Value masking in set_single/control_change/program_change is deliberate and unchanged.

Unknown effect slots raised two different exception types

ModuleSlot.parse() raised a bare ValueError, so slot_enable_cc("nope") and SlotEnable("nope", True).message() escaped an except LibKPError, while DeviceModel.set_effect_enabled("nope", True) raised UnknownSlotError for the same input. All four entry points now raise UnknownSlotError. The # pragma: no cover - defensive was wrong — the branch is reachable from two public functions.

TimeoutErrorLibKP was not exported

Every other member of libkp.errors.__all__ is re-exported from the package root, but this one — raised by both Session.connect and Session.handshake — was missing.

meters.run() leaked the session on a setup failure

The view, the event subscription, and the terminal takeover all happened outside the try, so a write failure after DeviceModel.connect (a closed pipe, say) left the socket and the ingest/writer tasks running.

Tests

test_meters_example.py and the new test_meters_tui.py assert that no row overflows or gets truncated at 72/80/100/120/160 columns; both fail against the old constants. The TUI file's helper tests run unconditionally and the layout test importorskips textual, so it skips under CI's pip install -e ".[dev]" — it passes locally with the tui extra.

New test_public_api.py guards the export surface, and test_conformance.py now rejects an empty vector case list, which would otherwise make its parametrize collect zero tests and pass green (Swift already guards this).

331 passed, 5 skipped; ruff check and ruff format --check clean.

Looked at, deliberately not changed

  • Discovery assumes a /24. broadcast_targets builds x.y.z.255, while Rust and Swift read the real netmask via if_addrs/getifaddrs and compute the true subnet broadcast for every interface. On a /16 or /22 LAN a Profiler outside the client's /24 never sees the directed broadcast. A stdlib-only fix needs fcntl/SIOCGIFNETMASK, which is Unix-specific; the docstring already flags the compromise. Worth a follow-up.
  • Cabinet.on is never populated. Page 0x0C/2 falls through to the generic ParamChanged, so the TUI's cab on/off dot can never light. Rust and Swift declare the same field and also never fill it, and there is no spec constant for the address — this is a shared spec gap, not a Python bug, so fixing it here alone would break tri-language parity.
  • discover() sorts reply IPs as strings, so find_first() returns the lexicographically lowest address rather than the first one seen. Swift does the same; only Rust sorts numerically. Changing Python alone would trade one divergence for another.
  • NrpnHeader.parse does not verify the trailing 0xF7 and unconditionally drops the last byte. Matches Rust/Swift, and every path that consumes the result already length-checks.
  • DeviceModel.connect never consults HandshakeOutcome.accepted. Tightening it would reject devices whose ack misses the 30 ms idle window, which seems worse than the status quo.

🤖 Generated with Claude Code

https://claude.ai/code/session_016BcyRYArvAWDXdS8KZjVju

Terminal layout, both examples:

- meters._default_width() left the bar six columns too wide, so every
  meter row overflowed the terminal and wrapped, tearing the
  full-screen frame apart on any window from ~57 to ~110 columns. The
  magic 26 is now a derived ROW_CHROME constant.
- meters_tui._bar_width() was over-generous by the same kind of margin.
  Rich crops an over-wide grid cell with an ellipsis rather than
  wrapping, so the right-hand end of every bar — including the
  peak-hold marker — was silently chopped off at 80 and 100 columns.
- The TUI's meters table hard-coded a 16-column label, but the --all
  labels run to 18 ("v2 strobe seg high"), so three of the eleven raw
  rows wrapped onto a second line. The label column is now derived from
  the widest label the table can show.

Messages and errors:

- nrpn.sysex() masked its header bytes to seven bits, so a caller
  mistake such as DeviceModel.set_param(0x80, 4, v) was silently
  retargeted at page 0 — the string/morph page — instead of surfacing.
  It now raises ValueError, matching protocol.push_field.
- ModuleSlot.parse() raised a bare ValueError for an unknown slot, so
  slot_enable_cc("nope") and SlotEnable("nope", True).message() escaped
  an `except LibKPError`, while DeviceModel.set_effect_enabled raised
  UnknownSlotError for the same input. All four paths now agree.
- libkp.__init__ exported every error class except TimeoutErrorLibKP,
  which Session.connect and Session.handshake both raise.

Robustness:

- meters.run() built its view and took over the terminal outside the
  try, so a write failure after connect leaked the socket and the
  ingest/writer tasks.

Tests cover each fix; the TUI layout test skips without the tui extra.
test_public_api guards the export surface and test_conformance now
rejects an empty vector case list, which would otherwise collect zero
tests and pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016BcyRYArvAWDXdS8KZjVju
@gotwalt
gotwalt merged commit 44ca978 into main Aug 21, 2026
5 checks passed
gotwalt added a commit that referenced this pull request Aug 24, 2026
Fix five correctness bugs in the Python wrapper
@gotwalt
gotwalt deleted the python-review-fixes branch August 24, 2026 19:20
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.

1 participant