pgntui 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {pgntui-0.3.0 → pgntui-0.3.2}/CHANGELOG.md +16 -0
  2. {pgntui-0.3.0 → pgntui-0.3.2}/PKG-INFO +1 -1
  3. pgntui-0.3.2/docs/audits/2026-06-04-round2-audit.md +71 -0
  4. pgntui-0.3.2/docs/audits/2026-06-04-round3-audit.md +79 -0
  5. {pgntui-0.3.0 → pgntui-0.3.2}/packaging/homebrew/pgntui.rb +1 -1
  6. {pgntui-0.3.0 → pgntui-0.3.2}/packaging/winget/phobic.pgntui.yaml +2 -2
  7. {pgntui-0.3.0 → pgntui-0.3.2}/pyproject.toml +1 -1
  8. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/__init__.py +1 -1
  9. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/__main__.py +7 -0
  10. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/app.py +2 -2
  11. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/recording/reader.py +5 -2
  12. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_app_record_toggle.py +55 -0
  13. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_example_workspace.py +10 -0
  14. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_recording_roundtrip.py +4 -8
  15. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_replay_mode.py +8 -5
  16. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_smoke.py +1 -1
  17. {pgntui-0.3.0 → pgntui-0.3.2}/.github/workflows/ci.yml +0 -0
  18. {pgntui-0.3.0 → pgntui-0.3.2}/.github/workflows/release.yml +0 -0
  19. {pgntui-0.3.0 → pgntui-0.3.2}/.gitignore +0 -0
  20. {pgntui-0.3.0 → pgntui-0.3.2}/LICENSE +0 -0
  21. {pgntui-0.3.0 → pgntui-0.3.2}/README.md +0 -0
  22. {pgntui-0.3.0 → pgntui-0.3.2}/docs/audits/2026-06-04-audit-A-concurrency.md +0 -0
  23. {pgntui-0.3.0 → pgntui-0.3.2}/docs/audits/2026-06-04-audit-B-decoding.md +0 -0
  24. {pgntui-0.3.0 → pgntui-0.3.2}/docs/audits/2026-06-04-audit-C-platform.md +0 -0
  25. {pgntui-0.3.0 → pgntui-0.3.2}/docs/audits/2026-06-04-audit-D-packaging.md +0 -0
  26. {pgntui-0.3.0 → pgntui-0.3.2}/docs/audits/2026-06-04-release-notes-v0.3.0.md +0 -0
  27. {pgntui-0.3.0 → pgntui-0.3.2}/docs/audits/2026-06-04-summary.md +0 -0
  28. {pgntui-0.3.0 → pgntui-0.3.2}/docs/superpowers/plans/2026-06-04-pgntui-implementation.md +0 -0
  29. {pgntui-0.3.0 → pgntui-0.3.2}/docs/superpowers/specs/2026-06-04-pgntui-design.md +0 -0
  30. {pgntui-0.3.0 → pgntui-0.3.2}/packaging/README.md +0 -0
  31. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/config.py +0 -0
  32. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/containers/__init__.py +0 -0
  33. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/containers/loader.py +0 -0
  34. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/containers/screen.py +0 -0
  35. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/debug/__init__.py +0 -0
  36. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/debug/tab.py +0 -0
  37. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/decode/__init__.py +0 -0
  38. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/decode/canboat.py +0 -0
  39. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/decode/fastpacket.py +0 -0
  40. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/decode/pgns.json +0 -0
  41. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/decode/router.py +0 -0
  42. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/drivers/__init__.py +0 -0
  43. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/drivers/actisense.py +0 -0
  44. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/drivers/base.py +0 -0
  45. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/drivers/replay.py +0 -0
  46. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/__init__.py +0 -0
  47. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/config.toml +0 -0
  48. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/containers/main.json +0 -0
  49. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/signals/anchor_light.json +0 -0
  50. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/signals/bilge_alarm.json +0 -0
  51. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/signals/depth.json +0 -0
  52. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/signals/engine_rpm.json +0 -0
  53. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/signals/speed.json +0 -0
  54. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/signals/target_heading.json +0 -0
  55. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/examples/signals/water_temp.json +0 -0
  56. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/logging/__init__.py +0 -0
  57. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/logging/csv.py +0 -0
  58. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/recording/__init__.py +0 -0
  59. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/recording/writer.py +0 -0
  60. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/replay_mode.py +0 -0
  61. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/signals/__init__.py +0 -0
  62. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/signals/base.py +0 -0
  63. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/signals/widgets.py +0 -0
  64. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/__init__.py +0 -0
  65. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/builtin/__init__.py +0 -0
  66. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/builtin/amber-crt.json +0 -0
  67. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/builtin/dark.json +0 -0
  68. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/builtin/green-phosphor.json +0 -0
  69. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/builtin/light.json +0 -0
  70. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/builtin/mono-ascii.json +0 -0
  71. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/builtin/rainbow-disco.json +0 -0
  72. {pgntui-0.3.0 → pgntui-0.3.2}/src/pgntui/themes/loader.py +0 -0
  73. {pgntui-0.3.0 → pgntui-0.3.2}/tests/__init__.py +0 -0
  74. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/__init__.py +0 -0
  75. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/e2e_containers/engine.json +0 -0
  76. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/e2e_containers/nav.json +0 -0
  77. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/e2e_session.pgnlog +0 -0
  78. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/e2e_signals/engine_rpm.json +0 -0
  79. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/e2e_signals/wind_speed.json +0 -0
  80. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/frames.py +0 -0
  81. {pgntui-0.3.0 → pgntui-0.3.2}/tests/fixtures/sample.pgnlog +0 -0
  82. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_actisense_driver.py +0 -0
  83. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_app_empty_welcome.py +0 -0
  84. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_app_frame_loop.py +0 -0
  85. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_app_on_write.py +0 -0
  86. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_app_quit_binding.py +0 -0
  87. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_app_shell.py +0 -0
  88. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_app_worker_cancel_on_exit.py +0 -0
  89. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_builtin_themes.py +0 -0
  90. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_canboat.py +0 -0
  91. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_cli.py +0 -0
  92. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_config.py +0 -0
  93. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_container_screen.py +0 -0
  94. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_containers_loader.py +0 -0
  95. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_csv_logger.py +0 -0
  96. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_debug_tab.py +0 -0
  97. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_drivers_base.py +0 -0
  98. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_e2e_replay.py +0 -0
  99. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_fastpacket.py +0 -0
  100. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_main_replay.py +0 -0
  101. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_packaging.py +0 -0
  102. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_recording_writer.py +0 -0
  103. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_replay_driver.py +0 -0
  104. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_router.py +0 -0
  105. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_signals_base.py +0 -0
  106. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_signals_loader.py +0 -0
  107. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_themes_loader.py +0 -0
  108. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_widgets_analog_in.py +0 -0
  109. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_widgets_analog_out.py +0 -0
  110. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_widgets_digital_in.py +0 -0
  111. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_widgets_digital_out.py +0 -0
  112. {pgntui-0.3.0 → pgntui-0.3.2}/tests/test_writer_race.py +0 -0
@@ -5,6 +5,20 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.2] — 2026-06-04
9
+
10
+ ### Fixed
11
+ - Recording writer errors during `_handle_frame` now surface in the status bar instead of being silently swallowed.
12
+ - `pgntui --example` targeting an existing file path now prints a clean error instead of a `NotADirectoryError` traceback.
13
+
14
+ ## [0.3.1] — 2026-06-04
15
+
16
+ ### Fixed
17
+ - Recording reader now parses timestamps as UTC. Replay frame deltas were unaffected, but CSV export and debug log absolute timestamps were off by the local UTC offset.
18
+
19
+ ### Changed
20
+ - `test_iter_frames_honors_pause_with_sliding_resume` now polls instead of hard-sleeping; faster suite, less flake attractor.
21
+
8
22
  ## [0.3.0] — 2026-06-04
9
23
 
10
24
  Bedtime audit fix wave: concurrency, decoding, packaging, recording, platform paths,
@@ -87,6 +101,8 @@ validation, and CI gating. Captures audits A/B/C/D from `docs/audits/2026-06-04-
87
101
 
88
102
  Initial published release.
89
103
 
104
+ [0.3.2]: https://github.com/phobicdotno/pgntui/releases/tag/v0.3.2
105
+ [0.3.1]: https://github.com/phobicdotno/pgntui/releases/tag/v0.3.1
90
106
  [0.3.0]: https://github.com/phobicdotno/pgntui/releases/tag/v0.3.0
91
107
  [0.2.2]: https://github.com/phobicdotno/pgntui/releases/tag/v0.2.2
92
108
  [0.2.0]: https://github.com/phobicdotno/pgntui/releases/tag/v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgntui
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Cross-platform TUI for NMEA 2000 with canboat decoding and pluggable drivers
5
5
  Project-URL: Homepage, https://github.com/phobicdotno/pgntui
6
6
  Project-URL: Source, https://github.com/phobicdotno/pgntui
@@ -0,0 +1,71 @@
1
+ # Round 2 Audit (2026-06-04, post fix-wave, pre 0.3.0)
2
+ Branch: main @ 010e7de
3
+
4
+ ## Verification Results
5
+
6
+ All 23 Round 1 findings verified PASS at HEAD.
7
+
8
+ | Finding | Status | Evidence |
9
+ |---|---|---|
10
+ | A-1 | PASS | `app.py:346-351` iterates workers, calls `cancel()` on frame_loop group before `exit()` |
11
+ | A-2 | PASS | `_writer_lock` guards `_handle_frame` snapshot (185-187) and `_stop_recording` swap (312-314) |
12
+ | A-3 | PASS | `actisense.py:122` checks `self._stop.is_set()` at top of `while True` |
13
+ | A-4 | PASS | `replay.py:73,79,97,109` — stop event respected in sleep and yield points |
14
+ | A-5 | PASS | `__main__.py:255-260` — writer backstop in finally |
15
+ | B-1 | PASS | `canboat.py:126-142` applies offset + resolution |
16
+ | B-2 | PASS | `canboat.py:66,92` constructs and routes via `FastPacketReassembler` |
17
+ | B-4 | PASS | `router.py:44` — None != 2 correctly skips |
18
+ | B-6 | PASS | Writer/reader column order match; Frame priority+destination roundtrip |
19
+ | B-7 | PASS | `containers/loader.py:45-63` rejects overlap |
20
+ | B-8 | PASS | `themes/loader.py:54-61` validates gradient stops |
21
+ | C-1 | PASS | `config.py:10` uses platformdirs |
22
+ | C-2 | PASS | `signals/base.py:114-117,129-132` coerces thresholds to float |
23
+ | C-3 | PASS | `pgntui.spec:9-12` includes examples/ + copy_metadata |
24
+ | C-4 | PASS | `writer.py:23` uses `newline=""` |
25
+ | C-5 | PASS | `config.py:46-47` catches TOMLDecodeError |
26
+ | C-6 | PASS | `pyproject.toml:28` — `textual>=8.0` |
27
+ | C-7 | PASS | `app.py:113` — typed `_view_pairs`; no property |
28
+ | D-I1 | PASS | release.yml — test:10 / binaries:30 timeouts |
29
+ | D-I2 | PASS | pypi + binaries declare `needs: test` |
30
+ | D-I3 | PASS | pyproject.toml has [project.urls] |
31
+ | D-I4 | PASS | README expanded to 174 lines |
32
+ | D-I5 | PASS | packaging/README.md exists with runbook |
33
+
34
+ ## New Findings
35
+
36
+ ### NF-1: Reader parses UTC timestamps as local time
37
+ - **File:** `src/pgntui/recording/reader.py:17`
38
+ - **Severity:** P2 (replay speed correct via frame deltas; absolute timestamps off by UTC offset)
39
+ - **What's wrong:** Writer formats with `tz=UTC`. Reader calls `datetime.strptime(...).timestamp()` on naive datetime, which Python interprets as local time. In UTC+2 every replayed timestamp is 7200s low. CSV export and debug log timestamps are wrong by the local UTC offset.
40
+ - **Fix:** `datetime.strptime(parts[0], "%Y-%m-%d-%H:%M:%S.%f").replace(tzinfo=UTC).timestamp()` — one line.
41
+
42
+ ### NF-2: Hard 1.5s sleep in pause test
43
+ - **File:** `tests/test_replay_mode.py:95`
44
+ - **Severity:** Low (CI flake attractor; no prod impact)
45
+ - **What's wrong:** `time.sleep(1.5)` to assert no frames during pause. Accumulates suite time and is fragile under scheduler jitter.
46
+ - **Fix:** Replace with polling loop — exit early on `len(emitted) > 1` (fast-fail) capped at a budget.
47
+
48
+ ## Cross-fix Interaction Notes
49
+
50
+ - **F3 + F4 (Offset + fast-packet):** Clean. `_decode_fields` applies offset on fully assembled payload. No fast-packet PGN in bundled DB has non-zero Offset, but code path is correct regardless.
51
+ - **F5 + F2 in actisense.py:** Clean. `_stop` event in `__init__` + read_frames; `parse_frame` returns Frame with priority/destination.
52
+ - **F6 + F2 in app.py:** Both present. Welcome text + worker cancel + _writer_lock + atomic swap all in HEAD.
53
+ - **F6 + F2 in __main__.py:** Both present. platformdirs config path + TOMLDecodeError catch + SIGINT writer backstop all in HEAD.
54
+ - **Writer/reader columns:** `[ts, priority, pgn, src, dst, len, *hex]` — exact alignment.
55
+ - **Fast-packet duplicate frame 0 (same seq_id):** Second overwrites buffer with fresh state — N2K retransmit handled.
56
+ - **Fast-packet malformed length=0xFF:** Buffer expires via `_expire_stale(5.0s)`. No garbage decode path.
57
+ - **FastPacketReassembler thread safety:** Owned by CanboatDecoder, called only from single frame_loop worker. No concurrent access.
58
+
59
+ ## Test Suite
60
+
61
+ - 156 tests passing
62
+ - Real threads in: test_writer_race, test_app_worker_cancel_on_exit, test_replay_mode — all use event-sync or generous-timeout polling except NF-2's flat sleep
63
+ - No network, no FS writes outside tmp_path
64
+ - No global state mutation; test_fastpacket patches time.monotonic in local context manager
65
+ - mypy --strict: clean
66
+ - ruff: clean
67
+ - No TODO/FIXME/HACK/XXX in src/
68
+
69
+ ## Verdict
70
+
71
+ **Clean to ship 0.3.0.** All Round 1 findings fixed. NF-1 and NF-2 are minor: reader UTC offset doesn't break replay (frame deltas are correct) and the test sleep is a flake attractor not a prod bug. Fix in 0.3.1.
@@ -0,0 +1,79 @@
1
+ # Round 3 Audit (post 0.3.1)
2
+ Branch: main @ 71d5f78
3
+
4
+ ## NF-1 + NF-2 Verification
5
+
6
+ - **NF-1 (reader UTC):** PASS — `recording/reader.py:20` does `.replace(tzinfo=UTC).timestamp()` correctly.
7
+ - **NF-1 test:** PASS — `test_recording_roundtrip.py:52-55` asserts `abs(restored.timestamp - written.timestamp) < 1e-3`.
8
+ - **NF-2 (polling pause):** PASS — `test_replay_mode.py` budget loop polls with 50ms quantum, fast-fails on leak. Sliding margin 20× test threshold.
9
+
10
+ ## Sweep A — Errors
11
+
12
+ | Severity | File:line | Issue |
13
+ |---|---|---|
14
+ | OK | reader.py:27 | `except (ValueError, IndexError)` — line parser, returns None |
15
+ | OK | fastpacket.py:158,167 | Skips bad PGN/length entries in pgns.json |
16
+ | OK | containers/themes/signals/config loaders | All raise typed exceptions |
17
+ | OK | __main__.py:139 | importlib.metadata fallback |
18
+ | OK | __main__.py:157 | driver.open exception → stderr + None (correct for optional hardware) |
19
+ | OK | app.py:175 | Frame loop safety net logs to UI |
20
+ | **SILENT** | **app.py:190** | **`except Exception: pass` on writer.write() — silent drop on disk full or closed file** |
21
+ | OK | app.py:273 | Pre-mount _set_status guard |
22
+ | OK | app.py:339,349 / __main__.py:250,259 | Defensive teardown finals |
23
+
24
+ ### NF-3 (P3): app.py:190 silent write error
25
+ - **What's wrong:** Recording writer.write() error silently swallowed. On full disk or closed file the recording silently stops while UI shows "REC -> filename".
26
+ - **Fix:** One-liner — `self.call_from_thread(self._set_status, f"rec error: {e}")` in the except block.
27
+
28
+ ## Sweep B — Resources
29
+
30
+ - All file handles via `with` blocks or explicit close in finally/close()
31
+ - `_writer_lock`, `_stop` Events properly initialized, no deadlock risk
32
+ - No subprocess, signal handlers, or fork calls
33
+ - `CSVSignalLogger` not currently wired into PgntuiApp (standalone) — no live handle leak
34
+
35
+ ## Sweep C — Public API
36
+
37
+ - `__init__.py` exports only `__version__`. Minimal.
38
+ - entry-points correct: `actisense-ngt1 → NGT1Driver`, `file-replay → FileReplayDriver`. Both verified to exist + implement Driver Protocol.
39
+ - Minor: `__main__.py` imports `_default_workspace` (leading-underscore) from config. Convention violation, not functional bug.
40
+
41
+ ## Sweep D — CLI UX
42
+
43
+ - `--help` documents `--workspace`, `--enable-write`, `--check`, `--example`, `replay` ✓
44
+ - `pgntui replay` (no file) → argparse usage error ✓
45
+ - `pgntui --example /path/to/file` → `iterdir()` raises `NotADirectoryError` traceback
46
+ - `pgntui --workspace /no/such/dir` → silent default config + welcome (reasonable)
47
+ - `pgntui replay --help` → argparse subparser help ✓
48
+
49
+ ### NF-4 (P3): __main__.py:59 --example handles file path poorly
50
+ - **What's wrong:** `--example` targeting an existing file (not dir) raises `NotADirectoryError` instead of clean message.
51
+ - **Fix:** One-liner — `if workspace.is_file(): print("error: workspace path is a file"); return 1` before iterdir.
52
+
53
+ ## Sweep E — Wheel hygiene
54
+
55
+ Verified via pyproject.toml static analysis (build not run):
56
+ - All force-include entries map to real files
57
+ - `decode/fastpacket.py` covered by packages=[src/pgntui] auto-discovery ✓
58
+ - `examples/__pycache__/` exists on disk (cosmetic; hatchling excludes by default)
59
+ - entry_points.txt derived correctly
60
+ - [project.urls] complete ✓
61
+
62
+ ## Sweep F — Docs accuracy
63
+
64
+ - README.md: install, quickstart, workspace, themes, hotkeys, status — all match code ✓
65
+ - packaging/README.md: runbook executable; example version `0.2.2` is a template (cosmetic)
66
+ - CHANGELOG.md: 0.3.1 + 0.3.0 entries match commits ✓
67
+ - release-notes-v0.3.0.md: test count 95→156 accurate ✓
68
+
69
+ ## Verdict
70
+
71
+ | Round | New P0/P1 | New P2 | New P3 |
72
+ |---|---|---|---|
73
+ | 1 | 5 | 13 | 12 |
74
+ | 2 | 0 | 1 (NF-1) | 1 (NF-2) |
75
+ | 3 | 0 | 0 | 2 (NF-3, NF-4) |
76
+
77
+ **Severity distribution this round: P0=0 P1=0 P2=0 P3=2.**
78
+
79
+ **Recommendation: STOP.** NF-3 and NF-4 are both edge-case P3s with trivial one-liner fixes. The codebase is clean to leave at 0.3.1, or alternatively ship 0.3.2 with both P3 fixes bundled (~10 min of work). After that, expected diminishing returns from further audit rounds.
@@ -3,7 +3,7 @@
3
3
  class Pgntui < Formula
4
4
  desc "Cross-platform TUI for NMEA 2000 with canboat decoding"
5
5
  homepage "https://github.com/phobicdotno/pgntui"
6
- url "https://files.pythonhosted.org/packages/source/p/pgntui/pgntui-0.3.0.tar.gz"
6
+ url "https://files.pythonhosted.org/packages/source/p/pgntui/pgntui-0.3.2.tar.gz"
7
7
  sha256 "REPLACE_ON_RELEASE"
8
8
  license "MIT"
9
9
 
@@ -1,6 +1,6 @@
1
1
  # Stub manifest for microsoft/winget-pkgs PR
2
2
  PackageIdentifier: phobic.pgntui
3
- PackageVersion: 0.3.0
3
+ PackageVersion: 0.3.2
4
4
  PackageLocale: en-US
5
5
  Publisher: phobic
6
6
  PackageName: pgntui
@@ -15,7 +15,7 @@ Tags: [nmea, marine, tui, terminal]
15
15
  Installers:
16
16
  - Architecture: x64
17
17
  InstallerType: portable
18
- InstallerUrl: https://github.com/phobicdotno/pgntui/releases/download/v0.3.0/pgntui-windows-x86_64.exe
18
+ InstallerUrl: https://github.com/phobicdotno/pgntui/releases/download/v0.3.2/pgntui-windows-x86_64.exe
19
19
  InstallerSha256: REPLACE_ON_RELEASE
20
20
  ManifestType: singleton
21
21
  ManifestVersion: 1.6.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pgntui"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Cross-platform TUI for NMEA 2000 with canboat decoding and pluggable drivers"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """pgntui — NMEA 2000 TUI."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -56,6 +56,13 @@ def scaffold_example(workspace: Path) -> int:
56
56
  Refuses to clobber an existing directory — the user must point at a fresh
57
57
  path or delete the old one.
58
58
  """
59
+ if workspace.is_file():
60
+ print(
61
+ f"workspace path {workspace} is a file, not a directory. "
62
+ "Pass --workspace <other> or delete the file.",
63
+ file=sys.stderr,
64
+ )
65
+ return 2
59
66
  if workspace.exists() and any(workspace.iterdir()):
60
67
  print(
61
68
  f"workspace exists at {workspace}, refusing to overwrite. "
@@ -187,8 +187,8 @@ class PgntuiApp(App[None]):
187
187
  if writer is not None:
188
188
  try:
189
189
  writer.write(frame)
190
- except Exception: # pragma: no cover — defensive
191
- pass
190
+ except Exception as e: # pragma: no cover — defensive
191
+ self.call_from_thread(self._set_status, f"rec error: {e}")
192
192
  decoded = self._decoder.decode(frame)
193
193
  if decoded is None:
194
194
  return
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Iterator
6
- from datetime import datetime
6
+ from datetime import UTC, datetime
7
7
  from pathlib import Path
8
8
 
9
9
  from pgntui.drivers.base import Frame
@@ -14,7 +14,10 @@ def parse_line(line: str) -> Frame | None:
14
14
  if len(parts) < 7:
15
15
  return None
16
16
  try:
17
- ts = datetime.strptime(parts[0], "%Y-%m-%d-%H:%M:%S.%f").timestamp()
17
+ # Writer emits UTC timestamps via datetime.utcfromtimestamp; parse
18
+ # them back as UTC so .timestamp() returns the original epoch value
19
+ # rather than shifting by the local UTC offset.
20
+ ts = datetime.strptime(parts[0], "%Y-%m-%d-%H:%M:%S.%f").replace(tzinfo=UTC).timestamp()
18
21
  priority = int(parts[1])
19
22
  pgn = int(parts[2])
20
23
  src = int(parts[3])
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
+ from typing import Any
6
7
 
7
8
  import pytest
8
9
 
@@ -94,3 +95,57 @@ async def test_record_writer_receives_frames(tmp_path: Path) -> None:
94
95
  assert writer.frame_count >= 0
95
96
  app.action_toggle_record()
96
97
  await pilot.pause()
98
+
99
+
100
+ class _RaisingWriter:
101
+ """Stand-in writer whose write() always blows up."""
102
+
103
+ def __init__(self, path: Path) -> None:
104
+ self.path = path
105
+ self.opened = False
106
+ self.closed = False
107
+
108
+ def open(self) -> None:
109
+ self.opened = True
110
+
111
+ def close(self) -> None:
112
+ self.closed = True
113
+
114
+ def write(self, _frame: Frame) -> None:
115
+ raise OSError("disk full")
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_record_write_error_surfaces_to_status(tmp_path: Path) -> None:
120
+ """When the writer's write() raises, the status bar shows 'rec error'."""
121
+ import asyncio
122
+
123
+ app = PgntuiApp(
124
+ theme=load_builtin("dark"),
125
+ containers=[],
126
+ decoder=CanboatDecoder.load_bundled(),
127
+ router=SignalRouter(),
128
+ record_dir=tmp_path / "rec",
129
+ )
130
+ async with app.run_test() as pilot:
131
+ await pilot.pause()
132
+ # Inject a raising writer directly so we don't depend on the real
133
+ # ActisenseLogWriter for failure simulation.
134
+ bad_writer: Any = _RaisingWriter(tmp_path / "rec" / "fake.pgnlog")
135
+ with app._writer_lock:
136
+ app._writer = bad_writer
137
+ app._writer_path = bad_writer.path
138
+ frame = Frame(timestamp=1.0, source_addr=23, pgn=127488, data=b"\x00" * 8)
139
+ # _handle_frame runs on the worker thread in production. Use
140
+ # asyncio.to_thread so `call_from_thread` runs from a non-event-loop
141
+ # thread and can marshal the status update back without deadlocking.
142
+ await asyncio.to_thread(app._handle_frame, frame)
143
+ await pilot.pause()
144
+ # Status bar should reflect the recording error rather than silently
145
+ # carrying on as if writes were succeeding.
146
+ from textual.widgets import Static
147
+
148
+ status = app.query_one("#status-bar", Static)
149
+ rendered = str(status.render())
150
+ assert "rec error" in rendered
151
+ assert "disk full" in rendered
@@ -65,3 +65,13 @@ def test_example_works_with_brand_new_workspace_dir(tmp_path: Path) -> None:
65
65
  ws.mkdir() # empty dir is OK
66
66
  rc = main(["--workspace", str(ws), "--example"])
67
67
  assert rc == 0
68
+
69
+
70
+ def test_example_refuses_file_path(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def]
71
+ """Pointing --example at an existing file (not dir) errors cleanly."""
72
+ tmp_file = tmp_path / "notadir.txt"
73
+ tmp_file.write_text("oops")
74
+ rc = main(["--workspace", str(tmp_file), "--example"])
75
+ assert rc == 2
76
+ captured = capsys.readouterr()
77
+ assert "is a file" in captured.err
@@ -49,14 +49,10 @@ def test_write_read_roundtrip_preserves_all_fields(tmp_path: Path) -> None:
49
49
  assert restored.data == written.data
50
50
  assert restored.priority == written.priority
51
51
  assert restored.destination == written.destination
52
- # NOTE: timestamp roundtrip has a pre-existing timezone mismatch
53
- # (writer formats as UTC, reader parses naively as local time). That's
54
- # out of scope for the B-6/C-4 fix and is left for a separate audit.
55
- # We still assert sub-second precision is preserved (ms via the writer's
56
- # "%Y-%m-%d-%H:%M:%S.%f"[:-3] truncation), modulo whole-second TZ offsets.
57
- sub_second_written = written.timestamp - int(written.timestamp)
58
- sub_second_restored = restored.timestamp - int(restored.timestamp)
59
- assert abs(sub_second_restored - sub_second_written) < 1e-3
52
+ # Reader now parses as UTC (NF-1), so the absolute timestamp survives
53
+ # the roundtrip modulo the writer's millisecond truncation
54
+ # ("%Y-%m-%d-%H:%M:%S.%f"[:-3] drops microsecond precision).
55
+ assert abs(restored.timestamp - written.timestamp) < 1e-3
60
56
 
61
57
 
62
58
  def test_writer_uses_lf_only(tmp_path: Path) -> None:
@@ -88,12 +88,15 @@ def test_iter_frames_honors_pause_with_sliding_resume(tmp_path: Path) -> None:
88
88
 
89
89
  # Pause immediately; the inter-frame sleep before frame 2 is in progress.
90
90
  # Frames 2 (+500ms) and 3 (+1000ms) would normally land within the next
91
- # ~1000ms. Sleep through a longer 1.5s window well past when both
92
- # frames would have emitted under nominal timing and confirm neither
93
- # arrives. The wider window absorbs CI runner jitter and scheduler stalls.
91
+ # ~1000ms. Poll across a 1.5s budget and fail-fast the moment a frame
92
+ # leaks through keeps the suite snappy while still absorbing CI jitter.
94
93
  s.toggle_pause()
95
- time.sleep(1.5)
96
- assert len(emitted) == 1, f"no frames should be emitted while paused; got {len(emitted)}"
94
+ budget_end = time.monotonic() + 1.5
95
+ while time.monotonic() < budget_end:
96
+ if len(emitted) > 1:
97
+ break # leak detected, will assert below
98
+ time.sleep(0.05)
99
+ assert len(emitted) == 1, f"pause leaked: emitted={len(emitted)}"
97
100
 
98
101
  # Resume — frame 2 should arrive after the leftover inter-frame delay
99
102
  # (sliding semantics), not instantly (which would mean catching up the
@@ -2,7 +2,7 @@ from pgntui import __version__
2
2
 
3
3
 
4
4
  def test_version() -> None:
5
- assert __version__ == "0.3.0"
5
+ assert __version__ == "0.3.2"
6
6
 
7
7
 
8
8
  def test_main_returns_zero() -> None:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes