Skip to content

Commit cf569f0

Browse files
committed
mypy and tests
1 parent 85f9ea8 commit cf569f0

16 files changed

Lines changed: 479 additions & 181 deletions

File tree

.github/workflows/tests.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272
run: uv run ruff check .
7373

7474
- name: Mypy
75-
# Non-blocking for now: flip `continue-on-error` to false once the
76-
# type surface is clean and you want mypy to gate merges.
77-
continue-on-error: true
78-
run: uv run mypy src/hackrfpy
75+
# Blocking. The package ships a py.typed marker, which promises
76+
# downstream type-checkers that these annotations are real; this gate is
77+
# what keeps that promise true.
78+
run: uv run mypy

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ Work on the current development branch. Entries move to a versioned section on
1111
release.
1212

1313
### Added
14+
- Type annotations across the entire shipped package, and `mypy` promoted to a
15+
blocking CI gate (`disallow_untyped_defs`). The package has always shipped a
16+
`py.typed` marker, which tells downstream type-checkers the inline annotations
17+
are real; previously only 1 of ~114 definitions was annotated, so that marker
18+
was a false promise. It is now enforced.
19+
- `HostOps` protocol (`_host.py`) making the contract between `HackRF` and the
20+
command mixins explicit and type-checkable. The mixins call ~27 methods on
21+
`self` that live on the host class; that dependency was previously implicit in
22+
a comment. Runtime composition and MRO are unchanged.
1423
- Continuous integration: GitHub Actions workflow running the test suite across
1524
Windows, Linux, and macOS on Python 3.11-3.13, plus a ruff + mypy lint job.
1625
- CLI test suite covering argument parsing, the mode state file, preset
@@ -42,6 +51,17 @@ release.
4251
the codebase made lint-clean so the CI lint job is meaningful.
4352

4453
### Fixed
54+
- The `atexit` backstop never reaped an orphaned `PersistentReceiver`.
55+
`PersistentReceiver` registers itself in the live-handle registry, whose
56+
shutdown hook calls `if h.is_alive(): h.stop()` -- but `is_alive()` did not
57+
exist on the class, so the resulting `AttributeError` was swallowed by the
58+
hook's bare `except Exception` and the receiver was silently left running.
59+
Found by the typing pass; `is_alive()` added and pinned with a regression test.
60+
- Reading from a closed `PersistentReceiver` raised a bare
61+
`TypeError: 'NoneType' object is not iterable` instead of a typed error; it now
62+
raises `HackRFDeviceError` with an actionable message.
63+
- `sweep_stream(..., print_cmd=True)` would hand `StreamCtx` a `None` and crash;
64+
it now raises `HackRFValueError` pointing at `sweep(..., print_cmd=True)`.
4565
- Unbounded memory growth on long-lived RX/TX handles: `_Process` drained child
4666
stdout/stderr into lists that were never trimmed, so an open-ended capture or
4767
repeat transmit retained every per-second stats line for the life of the

hackrfpy/pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ markers = [
5454
]
5555
testpaths = ["tests"]
5656

57+
[tool.mypy]
58+
python_version = "3.11"
59+
files = ["src/hackrfpy"]
60+
# The point of this config: the package ships a py.typed marker, which promises
61+
# downstream type-checkers that the inline annotations are real. These settings
62+
# are what make that promise true -- every def in the shipped package must be
63+
# annotated, or CI fails.
64+
disallow_untyped_defs = true
65+
disallow_incomplete_defs = true
66+
check_untyped_defs = true
67+
no_implicit_optional = true
68+
warn_redundant_casts = true
69+
warn_unused_ignores = true
70+
5771
[tool.ruff]
5872
# Match the codebase's existing style rather than reformatting it.
5973
line-length = 100

hackrfpy/src/hackrfpy/_commands/capture.py

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,30 @@
1818
# Author(s): <you>
1919
##--------------------------------------------------------------------\
2020

21+
from __future__ import annotations
22+
2123
import os
24+
from typing import TYPE_CHECKING, Any, Callable, Generator
25+
26+
import numpy as np
2227

2328
from .. import constants as C
29+
from .._host import HostOps
2430
from ..sigmf import write_sigmf_meta
2531

32+
if TYPE_CHECKING:
33+
from .._receiver import PersistentReceiver
34+
from .._stream_ctx import StreamCtx
35+
2636

27-
class CaptureMixin:
28-
def capture(self, freq, sample_rate, *, out="capture.iq",
29-
num_samples=None, duration=None,
30-
lna=16, vga=20, amp=False, bias_tee=False,
31-
baseband_bw=None, to_stdout=False, sigmf=True,
32-
segment_secs=None, print_cmd=False):
37+
class CaptureMixin(HostOps):
38+
def capture(self, freq: float, sample_rate: float, *,
39+
out: str = "capture.iq", num_samples: int | None = None,
40+
duration: float | None = None, lna: int = 16, vga: int = 20,
41+
amp: bool = False, bias_tee: bool = False,
42+
baseband_bw: float | None = None, to_stdout: bool = False,
43+
sigmf: bool = True, segment_secs: float | None = None,
44+
print_cmd: bool = False) -> Any:
3345
self.require_mode(C.MODE_RX)
3446
freq, sample_rate, lna, vga = self.validate_rx(freq, sample_rate, lna, vga)
3547
bw = self._auto_baseband(sample_rate, baseband_bw)
@@ -57,7 +69,7 @@ def capture(self, freq, sample_rate, *, out="capture.iq",
5769
# The inner stream generator is closed EXPLICITLY on the way out;
5870
# leaving it to GC can orphan a receiving hackrf_transfer after
5971
# the caller breaks out of the loop.
60-
def _gen():
72+
def _gen() -> Generator[np.ndarray, None, None]: # decoded, not raw
6173
tail = b""
6274
inner = self._run(argv, mode="stream")
6375
try:
@@ -97,17 +109,21 @@ def _gen():
97109
return res
98110

99111
# ---- aliases ----
100-
def rx(self, *a, **k):
112+
def rx(self, *a: Any, **k: Any) -> Any:
101113
return self.capture(*a, **k)
102114

103-
def capture_samples(self, freq, sample_rate, num_samples, **k):
115+
def capture_samples(self, freq: float, sample_rate: float,
116+
num_samples: int, **k: Any) -> Any:
104117
return self.capture(freq, sample_rate, num_samples=num_samples, **k)
105118

106-
def capture_seconds(self, freq, sample_rate, duration, **k):
119+
def capture_seconds(self, freq: float, sample_rate: float,
120+
duration: float, **k: Any) -> Any:
107121
return self.capture(freq, sample_rate, duration=duration, **k)
108122

109-
def scan_frequencies(self, freqs, sample_rate, num_samples, *,
110-
on_capture=None, **k):
123+
def scan_frequencies(self, freqs: list[float], sample_rate: float,
124+
num_samples: int, *,
125+
on_capture: Callable[..., Any] | None = None,
126+
**k: Any) -> dict[float, np.ndarray] | None:
111127
# Sequentially capture a fixed sample count at each frequency in
112128
# `freqs`, retuning between them. This does NOT close the gapless-
113129
# retune gap (each retune is a fresh hackrf_transfer with a short
@@ -130,8 +146,8 @@ def scan_frequencies(self, freqs, sample_rate, num_samples, *,
130146
return results if on_capture is None else None
131147

132148
# ---- in-memory + context-managed entry points ----
133-
def capture_array(self, freq, sample_rate, num_samples, *,
134-
return_params=False, **k):
149+
def capture_array(self, freq: float, sample_rate: float, num_samples: int,
150+
*, return_params: bool = False, **k: Any) -> Any:
135151
# Scripting entry point: return EXACTLY num_samples complex64 samples
136152
# in RAM, no file. Built on the stdout-stream path so it shares the
137153
# odd-byte carry + clean-reap logic. The stream is closed as soon as
@@ -164,8 +180,10 @@ def capture_array(self, freq, sample_rate, num_samples, *,
164180
return iq, self.last_params
165181
return iq
166182

167-
def open_receiver(self, freq, sample_rate, *, lna=16, vga=20, amp=False,
168-
baseband_bw=None, read_samples=131072):
183+
def open_receiver(self, freq: float, sample_rate: float, *, lna: int = 16,
184+
vga: int = 20, amp: bool = False,
185+
baseband_bw: float | None = None,
186+
read_samples: int = 131072) -> PersistentReceiver:
169187
# Open a PERSISTENT fixed-frequency receiver: one long-lived
170188
# hackrf_transfer you drain in segments over time, so you don't pay the
171189
# ~1-2 s process spin-up per capture. Use as a context manager:
@@ -184,7 +202,8 @@ def open_receiver(self, freq, sample_rate, *, lna=16, vga=20, amp=False,
184202
amp=amp, baseband_bw=baseband_bw,
185203
read_samples=read_samples)
186204

187-
def capture_stream(self, freq, sample_rate, **k):
205+
def capture_stream(self, freq: float, sample_rate: float,
206+
**k: Any) -> StreamCtx:
188207
# Context manager wrapping the live stdout stream so the receiving
189208
# hackrf_transfer is ALWAYS reaped on exit, even on exception:
190209
# with h.capture_stream(433.92e6, 8e6) as blocks:
@@ -194,8 +213,10 @@ def capture_stream(self, freq, sample_rate, **k):
194213
gen = self.capture(freq, sample_rate, to_stdout=True, **k)
195214
return StreamCtx(gen)
196215

197-
def capture_callback(self, freq, sample_rate, on_block, *,
198-
max_samples=None, max_blocks=None, **k):
216+
def capture_callback(self, freq: float, sample_rate: float,
217+
on_block: Callable[..., Any], *,
218+
max_samples: int | None = None,
219+
max_blocks: int | None = None, **k: Any) -> int:
199220
# Binder-style ergonomics over the subprocess stream: instead of the
200221
# caller writing the receive loop, register a callback that fires with
201222
# each decoded complex64 block as it arrives. This is the usability
@@ -228,8 +249,9 @@ def capture_callback(self, freq, sample_rate, on_block, *,
228249
return total
229250

230251
# ---- internals ----
231-
def _rx_argv(self, freq, sample_rate, target, lna, vga, amp, bias_tee,
232-
bw, num_samples):
252+
def _rx_argv(self, freq: float, sample_rate: float, target: str, lna: int,
253+
vga: int, amp: bool, bias_tee: bool, bw: float,
254+
num_samples: int | None) -> list[Any]:
233255
argv = ["transfer", "-r", target,
234256
"-f", int(freq), "-s", int(sample_rate),
235257
"-l", lna, "-g", vga,
@@ -241,8 +263,10 @@ def _rx_argv(self, freq, sample_rate, target, lna, vga, amp, bias_tee,
241263
argv += ["-n", int(num_samples)]
242264
return argv
243265

244-
def _capture_segmented(self, freq, sample_rate, out, segment_secs, lna,
245-
vga, amp, bias_tee, bw, sigmf, print_cmd):
266+
def _capture_segmented(self, freq: float, sample_rate: float, out: str,
267+
segment_secs: float, lna: int, vga: int, amp: bool,
268+
bias_tee: bool, bw: float, sigmf: bool,
269+
print_cmd: bool) -> list[str]:
246270
# Rolling files out_000.iq, out_001.iq, ... each `segment_secs` long.
247271
# Each segment is a bounded blocking capture, so files are whole.
248272
base, ext = os.path.splitext(out)

hackrfpy/src/hackrfpy/_commands/device.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,34 @@
1212
# Author(s): <you>
1313
##--------------------------------------------------------------------\
1414

15+
from __future__ import annotations
16+
1517
import re
1618
import shutil
19+
from typing import Any
1720

1821
from .. import constants as C
22+
from .._host import HostOps
1923
from ..exceptions import HackRFDeviceError, HackRFValueError
2024

2125

22-
class DeviceMixin:
26+
class DeviceMixin(HostOps):
2327
# ---- clock ----
24-
def clock(self, *args, print_cmd=False):
28+
def clock(self, *args: Any, print_cmd: bool = False) -> Any:
2529
argv = ["clock"] + list(args)
2630
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
2731

2832
# ---- operacake antenna switch ----
29-
def operacake(self, *args, print_cmd=False):
33+
def operacake(self, *args: Any, print_cmd: bool = False) -> Any:
3034
argv = ["operacake"] + list(args)
3135
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
3236

33-
def operacake_list(self):
37+
def operacake_list(self) -> Any:
3438
return self.operacake("-l")
3539

3640
# ---- cpld jtag (CPLD firmware) ----
37-
def cpldjtag(self, firmware, *, confirm=False, print_cmd=False):
41+
def cpldjtag(self, firmware: str, *, confirm: bool = False,
42+
print_cmd: bool = False) -> Any:
3843
if not confirm and not print_cmd:
3944
raise HackRFValueError(
4045
"cpldjtag flashes the CPLD and can brick the board. "
@@ -43,7 +48,8 @@ def cpldjtag(self, firmware, *, confirm=False, print_cmd=False):
4348
text=True, print_cmd=print_cmd)
4449

4550
# ---- spiflash (firmware) ----
46-
def spiflash_write(self, firmware, *, confirm=False, print_cmd=False):
51+
def spiflash_write(self, firmware: str, *, confirm: bool = False,
52+
print_cmd: bool = False) -> Any:
4753
# Writing firmware. The single most dangerous operation in the library.
4854
if not confirm and not print_cmd:
4955
raise HackRFValueError(
@@ -53,30 +59,31 @@ def spiflash_write(self, firmware, *, confirm=False, print_cmd=False):
5359
return self._run(["spiflash", "-w", firmware], mode="blocking",
5460
text=True, print_cmd=print_cmd)
5561

56-
def spiflash_read(self, out, length=None, print_cmd=False):
57-
argv = ["spiflash", "-r", out]
62+
def spiflash_read(self, out: str, length: int | None = None,
63+
print_cmd: bool = False) -> Any:
64+
argv: list[Any] = ["spiflash", "-r", out]
5865
if length is not None:
5966
argv += ["-l", int(length)]
6067
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
6168

62-
def spiflash_reset(self, print_cmd=False):
69+
def spiflash_reset(self, print_cmd: bool = False) -> Any:
6370
return self._run(["spiflash", "-R"], mode="blocking", text=True,
6471
print_cmd=print_cmd)
6572

6673
# ---- debug register access ----
67-
def debug(self, *args, print_cmd=False):
74+
def debug(self, *args: Any, print_cmd: bool = False) -> Any:
6875
argv = ["debug"] + list(args)
6976
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
7077

7178
# =================================================================
7279
# preflight: environment / readiness check
7380
# (was 'doctor' -- kept as an alias below for the familiar CLI verb)
7481
# =================================================================
75-
def preflight(self, capture_path="."):
82+
def preflight(self, capture_path: str = ".") -> dict[str, Any]:
7683
# Checks tooling, board presence, free disk, and reports the active
7784
# mode. Returns a structured report; raises only on hard environment
7885
# failures the user must fix.
79-
report = {"tools": {}, "boards": [], "mode": self.mode,
86+
report: dict[str, Any] = {"tools": {}, "boards": [], "mode": self.mode,
8087
"tool_version": None, "disk_free_bytes": None,
8188
"features": {}, "problems": []}
8289

@@ -133,10 +140,11 @@ def preflight(self, capture_path="."):
133140

134141
# doctor: familiar alias for preflight (brew/flutter-style verb). The CLI
135142
# still exposes `hrf doctor`; the honest method name is preflight().
136-
def doctor(self, capture_path="."):
143+
def doctor(self, capture_path: str = ".") -> dict[str, Any]:
137144
return self.preflight(capture_path=capture_path)
138145

139-
def features(self, tool_version=None, firmware_version=None):
146+
def features(self, tool_version: str | None = None,
147+
firmware_version: str | None = None) -> dict[str, Any]:
140148
# Map version strings -> capability flags. The reliable year signal is
141149
# the FIRMWARE version (e.g. "2024.02.1"); the tools version is often a
142150
# git tag (e.g. "git-b1dbb47") with no parseable year. If nothing is
@@ -157,7 +165,7 @@ def features(self, tool_version=None, firmware_version=None):
157165
# Prefer the firmware version's year; fall back to the tools version.
158166
# A "git-" build is a from-source build, which is current by
159167
# definition -- treat it as modern rather than merely "unknown".
160-
def _year(v):
168+
def _year(v: str | None) -> int | None:
161169
m = re.match(r"(\d{4})", v or "")
162170
return int(m.group(1)) if m else None
163171

@@ -177,7 +185,7 @@ def _year(v):
177185
"bias_tee": is_git or year is None or year >= 2018, # -p
178186
}
179187

180-
def _print_preflight(self, r):
188+
def _print_preflight(self, r: dict[str, Any]) -> None:
181189
print("hackrfpy preflight")
182190
print("-" * 40)
183191
for name, path in r["tools"].items():

0 commit comments

Comments
 (0)