Skip to content

Commit fed1ae1

Browse files
committed
logging fixes
1 parent f1524fa commit fed1ae1

4 files changed

Lines changed: 157 additions & 35 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,51 @@ release.
2121
`SECURITY.md`, issue templates, and a pull request template.
2222

2323
### Changed
24+
- Library diagnostics now go through the standard `logging` module instead of
25+
`print()`. Records are emitted on the `hackrfpy` logger: warnings at
26+
`WARNING`, verbose progress messages at `INFO`. A consumer can now route,
27+
reformat, or silence hackrfpy's output like any other library.
28+
- **Diagnostics no longer touch stdout.** `print_message` previously wrote to
29+
stdout, so `hrf sweep -v > out.csv` prepended `[*] mode: rx` into the CSV.
30+
stdout is now reserved for data (sweep CSV, IQ on `-r -`) and explicitly
31+
requested output (`--print-cmd`). Diagnostics go to stderr.
32+
- Console behavior is unchanged for scripts and the CLI: warnings still appear
33+
with no setup at all, and `verbose=True` / `-v` still prints progress. If the
34+
host application has configured logging, hackrfpy stays out of the way and
35+
simply propagates records to it.
2436
- Packaging classifiers: removed the contradictory `Operating System ::
2537
OS Independent` (the library shells out to the Windows `hackrf-tools`
2638
binaries and Linux/macOS operation is unverified), leaving `Operating System
2739
:: Microsoft :: Windows`. Development status raised from `3 - Alpha` to
2840
`5 - Production/Stable` to match the 1.0.0 release.
41+
- Ruff configuration added (`line-length = 100`, `select = ["E", "F", "W"]`), and
42+
the codebase made lint-clean so the CI lint job is meaningful.
2943

3044
### Fixed
45+
- Unbounded memory growth on long-lived RX/TX handles: `_Process` drained child
46+
stdout/stderr into lists that were never trimmed, so an open-ended capture or
47+
repeat transmit retained every per-second stats line for the life of the
48+
process. Both drain paths now share a 64 KB cap (`_DRAIN_CAP`).
49+
- `transmit()` now verifies the source file exists *before* arming TX and
50+
spawning `hackrf_transfer`, raising `HackRFEnvironmentError` instead of
51+
failing with a generic non-zero exit from the tool. The TX-mode gate is still
52+
checked first, and `print_cmd` dry runs skip the check.
53+
- SigMF sidecars now declare the `hackrf` namespace in `core:extensions`.
54+
Previously the `hackrf:*` gain keys were written without declaring the
55+
extension, which strict SigMF validators reject.
56+
- Removed unreachable dead code in `core.py` (an orphaned `return load_iq(...)`
57+
after a `return`, referencing three undefined names).
58+
- The test suite no longer writes a stray `capture.sigmf-meta` into the working
59+
directory on every run.
3160
- `CITATION.cff` version and release date corrected to `1.0.0` / `2026-06-16`,
3261
aligning the citation metadata with `pyproject.toml` and the tagged release.
3362

3463
<!--
3564
Roadmap for this branch (append entries above as each lands):
36-
- Fix: cap the _Process stdout/stderr drain buffers (unbounded growth on
37-
long-lived RX/TX handles).
38-
- Fix: validate the transmit source file exists before arming TX mode.
39-
- Fix: declare the `hackrf` namespace in SigMF `core:extensions`.
40-
- Changed: route library diagnostics through the `logging` module instead of
41-
print(); `warn`/`print_message` become thin wrappers. NOTE breaking-ish:
42-
library users who set verbose=True must configure a logging handler to see
43-
info-level messages (the CLI configures this automatically).
4465
- Added: type annotations across the public API; mypy promoted to a CI gate
4566
(makes the shipped py.typed marker accurate).
67+
- CI: --cov-fail-under=85; confirm the Windows leg exercises the CTRL_BREAK
68+
lifecycle path.
4669
- Docs: note that a single HackRF instance is not safe to share across
4770
threads (deferred pending broader testing).
4871
-->
@@ -69,4 +92,4 @@ Initial public release.
6992
and a parameter readback (`last_params`) reflecting the values actually used.
7093

7194
[Unreleased]: https://github.com/LC-Linkous/hackRF_python/compare/V1.0.0...HEAD
72-
[1.0.0]: https://github.com/LC-Linkous/hackRF_python/releases/tag/V1.0.0
95+
[1.0.0]: https://github.com/LC-Linkous/hackRF_python/releases/tag/V1.0.0

‎hackrfpy/src/hackrfpy/core.py‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
##--------------------------------------------------------------------\
2121

2222
import atexit
23+
import logging
2324
import os
2425
import shutil
2526
import signal
@@ -40,6 +41,35 @@
4041
from ._commands.sweep import SweepMixin
4142
from ._commands.device import DeviceMixin
4243

44+
45+
# ---- diagnostics channel ----------------------------------------------------
46+
# Library diagnostics go through logging, not print(), so a consumer can route,
47+
# filter, or silence them. Two rules hold, and the tests pin both:
48+
#
49+
# 1. Diagnostics NEVER touch stdout. stdout is for DATA (sweep CSV, an
50+
# IQ stream on `-r -`) and for explicitly-requested output (a --print-cmd
51+
# preview). `hrf sweep -v > out.csv` must not put "[*] mode: rx" in the CSV.
52+
# 2. Warnings fire regardless of verbose. A safety notice that only appears in
53+
# verbose mode is, in practice, a silent warning.
54+
#
55+
# No NullHandler is installed on purpose: with no handler anywhere, logging's
56+
# last-resort handler emits WARNING+ to stderr on its own, which preserves the
57+
# "warnings always show, zero setup required" contract for plain scripts.
58+
log = logging.getLogger("hackrfpy")
59+
60+
61+
def _ensure_console_logging():
62+
# The last-resort handler only emits WARNING and above, so verbose=True in a
63+
# plain script would otherwise print nothing at INFO. Attach a stderr handler
64+
# -- but ONLY if nobody has configured logging (neither our logger nor the
65+
# root). If the host application owns logging, stay out of its way and just
66+
# let records propagate.
67+
if log.handlers or logging.getLogger().handlers:
68+
return
69+
handler = logging.StreamHandler(sys.stderr)
70+
handler.setFormatter(logging.Formatter("%(message)s")) # bare: no level prefix
71+
log.addHandler(handler)
72+
4373
# ---- platform interrupt plumbing -------------------------------------------
4474
# hackrf_* tools flush + close cleanly on SIGINT. On Windows SIGINT can't be
4575
# delivered to a child; the equivalent is CTRL_BREAK_EVENT, which requires
@@ -178,7 +208,10 @@ def __exit__(self, exc_type, exc, tb):
178208
class HackRF(InfoMixin, CaptureMixin, TransmitMixin, SweepMixin, DeviceMixin):
179209
def __init__(self, tools_dir=None, verbose=False, serial=None):
180210
# ---- feedback ----
181-
self.verboseEnabled = verbose
211+
# via set_verbose so that HackRF(verbose=True) wires up the console
212+
# handler exactly like an explicit set_verbose(True) call would.
213+
self.verboseEnabled = False
214+
self.set_verbose(verbose)
182215

183216
# ---- device selection (multi-board setups) ----
184217
# When set, `-d <serial>` is injected into every tool that supports
@@ -225,20 +258,29 @@ def _record_params(self, **params):
225258
# =================================================================
226259
def set_verbose(self, verbose=True):
227260
self.verboseEnabled = verbose
261+
if verbose:
262+
# Make INFO actually visible in a plain script (see
263+
# _ensure_console_logging). No-op if the host app configured logging.
264+
_ensure_console_logging()
265+
if log.getEffectiveLevel() > logging.INFO:
266+
log.setLevel(logging.INFO)
228267

229268
def get_verbose(self):
230269
return self.verboseEnabled
231270

232271
def print_message(self, msg):
272+
# Progress / status chatter. INFO, and gated on verbose so the level and
273+
# the flag agree. Goes to stderr (never stdout) -- see the module note.
233274
if self.verboseEnabled:
234-
print(msg)
275+
log.info(msg)
235276

236277
def warn(self, msg):
237278
# Safety / correctness warnings the user must see REGARDLESS of verbose.
238279
# (A degraded-results or out-of-spec notice that only prints in verbose
239-
# mode is, in practice, a silent warning.) Routed to stderr so it never
240-
# pollutes stdout data streams (sweep CSV, rx -r -).
241-
print(f"[!] {msg}", file=sys.stderr)
280+
# mode is, in practice, a silent warning.) WARNING level, so it survives
281+
# with no logging setup at all via logging's last-resort stderr handler.
282+
# The "[!] " marker lives in the message so it shows under any formatter.
283+
log.warning(f"[!] {msg}")
242284

243285
# =================================================================
244286
# Operating mode machine
@@ -750,4 +792,4 @@ def load_iq(path: str, count: int | None = None,
750792
iq.real = a[0::2]
751793
iq.imag = a[1::2]
752794
iq /= 128.0
753-
return iq
795+
return iq

‎hackrfpy/tests/test_features.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# Last Update: July 11, 2026
1414
##--------------------------------------------------------------------\
1515

16+
import logging
1617
import time
1718

1819
import numpy as np
@@ -58,15 +59,16 @@ def test_capture_stream_reaps_on_exception(stub_device, tmp_path):
5859

5960

6061
# ---- from_device: fail-fast capability probe -------------------------------
61-
def test_from_device_probes_and_warns_on_old_firmware(stub_device, capsys):
62+
def test_from_device_probes_and_warns_on_old_firmware(stub_device, caplog):
6263
h = stub_device(info=dict(stdout_lines=[
6364
"hackrf_info version: 2019.12.1",
6465
"libhackrf version: 2019.12.1 (0.5)",
6566
"Found HackRF", "Index: 0",
6667
"Serial number: 000000000000000000000000deadbeef"]))
67-
dev = HackRF.from_device(tools_dir=h.tools_dir)
68+
with caplog.at_level(logging.WARNING, logger="hackrfpy"):
69+
dev = HackRF.from_device(tools_dir=h.tools_dir)
6870
assert dev._probed["boards"]
69-
assert "predates 2021" in capsys.readouterr().err
71+
assert "predates 2021" in "\n".join(r.message for r in caplog.records)
7072

7173

7274
def test_from_device_raises_when_no_board(stub_device):
@@ -103,15 +105,16 @@ def test_core_tools_are_subset_of_tools():
103105

104106

105107
# ---- sweep: sub-MHz edges warn ---------------------------------------------
106-
def test_sweep_warns_on_sub_mhz_edges(capsys, monkeypatch):
108+
def test_sweep_warns_on_sub_mhz_edges(caplog, monkeypatch):
107109
h = HackRF()
108110
def empty_stream(*a, **k):
109111
if False:
110112
yield
111113
monkeypatch.setattr(h, "_run", empty_stream)
112-
gen = h.sweep(433_920_000, 434_500_000)
113-
list(gen)
114-
assert "snapped to MHz" in capsys.readouterr().err
114+
with caplog.at_level(logging.WARNING, logger="hackrfpy"):
115+
gen = h.sweep(433_920_000, 434_500_000)
116+
list(gen)
117+
assert "snapped to MHz" in "\n".join(r.message for r in caplog.records)
115118

116119

117120
# ---- tx: max_duration converts an open-ended repeat into a timed run -------
@@ -125,4 +128,4 @@ def test_tx_max_duration_forces_timed(monkeypatch, tmp_path):
125128
lambda argv, **k: seen.update(k) or ("", "", 0))
126129
h.transmit(433.92e6, 8e6, str(src), repeat=True, max_duration=5.0)
127130
assert seen.get("mode") == "timed"
128-
assert seen.get("duration") == 5.0
131+
assert seen.get("duration") == 5.0

‎hackrfpy/tests/test_safety.py‎

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# Last Update: July 11, 2026
1414
##--------------------------------------------------------------------\
1515

16+
import logging
17+
1618
import pytest
1719

1820
from hackrfpy import HackRF, parse_freq, constants as C
@@ -50,27 +52,42 @@ def fake_run(argv, **k):
5052

5153

5254
# ---- warn channel: safety warnings must fire regardless of verbose ----------
53-
def test_snap_gain_warns_on_stderr_when_not_verbose(capsys):
55+
# Warnings now go through logging ("hackrfpy" logger, WARNING level) instead of
56+
# a raw stderr write, so these assert on caplog. The stdout-purity invariant is
57+
# still checked with capsys: diagnostics must NEVER land on stdout.
58+
def test_snap_gain_warns_when_not_verbose(caplog):
5459
h = HackRF(verbose=False)
55-
snapped = h._snap_gain("lna", 7, C.LNA_GAIN) # 7 -> 0, a silent 7 dB loss
60+
with caplog.at_level(logging.WARNING, logger="hackrfpy"):
61+
snapped = h._snap_gain("lna", 7, C.LNA_GAIN) # 7 -> 0, a silent 7 dB loss
5662
assert snapped == 0
57-
err = capsys.readouterr().err
58-
assert "lna" in err and "-> 0" in err # user was told, on stderr
63+
msgs = "\n".join(r.message for r in caplog.records)
64+
assert "lna" in msgs and "-> 0" in msgs # user was told, despite verbose=False
5965

6066

61-
def test_sub_recommended_sample_rate_warns_not_silent(capsys):
67+
def test_sub_recommended_sample_rate_warns_not_silent(caplog):
6268
h = HackRF(verbose=False)
63-
h._check_hard_range("sample_rate", 4e6, C.SR_MIN, C.SR_MAX, C.SR_WARN_BELOW)
64-
assert "below the recommended" in capsys.readouterr().err
69+
with caplog.at_level(logging.WARNING, logger="hackrfpy"):
70+
h._check_hard_range("sample_rate", 4e6, C.SR_MIN, C.SR_MAX, C.SR_WARN_BELOW)
71+
assert "below the recommended" in "\n".join(r.message for r in caplog.records)
6572

6673

67-
def test_forced_out_of_spec_warns_on_stderr(capsys):
74+
def test_forced_out_of_spec_warns_and_never_touches_stdout(caplog, capsys):
6875
h = HackRF()
6976
h.allow_out_of_spec = True
70-
h._check_hard_range("frequency", 60e9, C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
71-
out, err = capsys.readouterr()
72-
assert "forced out-of-spec" in err
73-
assert out == "" # never pollutes stdout
77+
with caplog.at_level(logging.WARNING, logger="hackrfpy"):
78+
h._check_hard_range("frequency", 60e9, C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
79+
assert "forced out-of-spec" in "\n".join(r.message for r in caplog.records)
80+
assert capsys.readouterr().out == "" # never pollutes stdout
81+
82+
83+
def test_warnings_are_warning_level(caplog):
84+
# Level matters: a consumer filtering at WARNING must still receive safety
85+
# notices. Pinned so a future refactor can't quietly demote these to INFO.
86+
h = HackRF(verbose=False)
87+
with caplog.at_level(logging.WARNING, logger="hackrfpy"):
88+
h._snap_gain("lna", 7, C.LNA_GAIN)
89+
assert caplog.records
90+
assert all(r.levelno == logging.WARNING for r in caplog.records)
7491

7592

7693
# ---- typed frequency parsing ------------------------------------------------
@@ -184,3 +201,40 @@ def test_transmit_print_cmd_skips_source_check(monkeypatch):
184201
h.transmit(433.92e6, 8e6, "/no/such/file.iq", print_cmd=True)
185202
assert seen["argv"][0] == "transfer"
186203
assert "-t" in seen["argv"]
204+
205+
206+
# ---- logging contract: what a consumer is entitled to rely on ---------------
207+
def test_diagnostics_never_reach_stdout(capsys, caplog):
208+
# The reason print_message moved off stdout: `hrf sweep -v > out.csv` used
209+
# to prepend "[*] mode: rx" INTO the CSV. stdout is data; stderr is chatter.
210+
h = HackRF(verbose=True)
211+
with caplog.at_level(logging.INFO, logger="hackrfpy"):
212+
h.print_message("[*] progress chatter")
213+
h.warn("a safety warning")
214+
assert capsys.readouterr().out == ""
215+
assert len(caplog.records) == 2
216+
217+
218+
def test_consumer_can_silence_the_library(caplog):
219+
# Raising the level on the "hackrfpy" logger must suppress our records --
220+
# impossible back when these were bare print() calls.
221+
h = HackRF(verbose=False)
222+
logger = logging.getLogger("hackrfpy")
223+
with caplog.at_level(logging.CRITICAL, logger="hackrfpy"):
224+
logger.setLevel(logging.CRITICAL)
225+
try:
226+
h.warn("should be suppressed")
227+
finally:
228+
logger.setLevel(logging.NOTSET) # restore; logger is global
229+
assert not [r for r in caplog.records if "suppressed" in r.message]
230+
231+
232+
def test_verbose_gating_still_holds(caplog):
233+
# verbose=False -> no INFO; verbose=True -> INFO flows.
234+
with caplog.at_level(logging.INFO, logger="hackrfpy"):
235+
quiet = HackRF(verbose=False)
236+
quiet.print_message("[*] invisible")
237+
assert not caplog.records
238+
quiet.set_verbose(True)
239+
quiet.print_message("[*] now visible")
240+
assert any("now visible" in r.message for r in caplog.records)

0 commit comments

Comments
 (0)