Plans (docs/plans/): - 2026-07-01-23-47-py-hxprobe-httpx.md — initial httpx probe design - 2026-07-02-09-32 through 14-05 — standalone project, toolchain, usage doc + Makefile, file input (-f), simplification pass, run-summary footer Summaries (docs/summaries/): one per completed feature, recording what was actually built, deviations from the plan, and verification steps Explanations (docs/explanations/): two deep-dives written during review — hxprobe concurrency model and worst-exit-code + render-loop analysis Usage (docs/usage/hxprobe.md): overview with pointer to hxprobe/USAGE.md for the full runnable reference Walkthrough (docs/py-latprobe-walkthrough.md): narrative tour of the latprobe Python package for interview / code-review context CHANGELOG.md: entries for all hxprobe features (toolchain, usage doc, file input, simplification, run-summary footer) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.4 KiB
hxprobe simplification plan
Context
The hxprobe Python package works and is well-tested, but it carries several
pieces of speculative scaffolding — generality added for things that "might
happen later" — plus some redundant state and duplicated logic. The
user's explicit directive: keep the code simple, with no unnecessary
abstractions or scaffolding for hypothetical future features.
This plan removes that overhead without changing any observable behavior. All
existing tests in tests/test_cli.py and tests/test_probe.py must continue
to pass unchanged (they are the behavioral contract). Net effect: ~40–50 fewer
lines, fewer moving parts, no new abstractions.
The trigger was a question about the _Parser._print_message/print_help/
print_usage overrides, whose file= parameter is accepted but never used —
the classic "kept for a future that never came" smell. Investigation showed the
whole _Parser subclass re-implements behavior the standard library already
provides.
Changes
1. Delete the _Parser subclass — use stdlib stream redirection
File: hxprobe/hxprobe/cli.py (lines 60–94, and the run() wiring ~375–455)
The _Parser class + _ArgExit exception (~32 lines) exist only to (a) route
argparse's help/usage/error output to the injected stdout/stderr streams and
(b) raise instead of calling sys.exit(). The standard library already does
both:
argparse.ArgumentParserwrites help tosys.stdoutand usage/errors tosys.stderrby default, and already raisesSystemExit(not a hard exit) — so it is already testable.contextlib.redirect_stdout(stdout)/redirect_stderr(stderr)patch the streams argparse writes to.
Do:
- Remove
class _ArgExit,class _Parser, and all four overrides (_print_message,print_help,print_usage,error,exit). - In
run(), build a plainargparse.ArgumentParser(prog="hxprobe", ...)(drop theout=/err=kwargs). - Wrap the parse + the two manual validations in redirection and catch
SystemExit:
import contextlib
...
try:
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
ns = parser.parse_args(args)
if ns.urls and ns.file:
parser.error("cannot combine positional url arguments with -f/--file")
if not ns.urls and not ns.file:
parser.error("no URLs given (pass as arguments or with -f/--file)")
except SystemExit as exc:
return EXIT_OK if not exc.code else EXIT_USAGE
Behavior parity (verified against tests):
-h→ argparse prints help tostdout, raisesSystemExit(0)→ returnsEXIT_OK. (test_help_shows_hxprobe_prog_name,test_help_lists_protocol_flags)parser.error(...)→ printsprog: error: msg+ usage tostderr, raisesSystemExit(2)→ mapped toEXIT_USAGE. (test_*_is_usage_error)- Note: stdlib
error()exits with code 2; we normalise any non-zero parse exit toEXIT_USAGE(1) at the single catch site, replacing the per-method code baked into the old override.
Only the parse/validate block needs redirection; the rest of run() keeps
writing directly to the passed stdout/stderr.
2. Trim _TimingStream.get_extra_info to the branch that's actually used
File: hxprobe/hxprobe/probe.py (lines 262–275)
server_addr and client_addr are only ever queried by httpx/_main.py (the
httpx CLI command), never by the request path hxprobe drives — verified by
grepping the installed httpcore/httpx. The only branch httpcore's sync
connection path calls is ssl_object (and is_readable, which we intentionally
leave unhandled → None).
Do: reduce the method to:
def get_extra_info(self, info: str):
if info == "ssl_object" and isinstance(self._sock, ssl.SSLSocket):
return self._sock
return None
3. Drop the _load_urls "future annotations" scaffolding
File: hxprobe/hxprobe/cli.py (lines 358–369)
The line.split()[0] + docstring ("forward-compatible with future
'url key=value' annotations") is scaffolding for a feature that doesn't exist.
Use the stripped line directly and simplify the docstring to describe what it
actually does (one URL per line, # comments and blank lines skipped).
test_reads_urls_from_file (comments + blanks) still passes.
4. Remove redundant _set flags in _Trace
File: hxprobe/hxprobe/probe.py (lines 126–191)
_dns_set, _connect_set, _tls_set duplicate information already carried by
Phase.present on the corresponding self.dns / self.connect / self.tls.
The initial Phase() has present=False, so the first-hop-wins guard is
identical.
Do: delete the three boolean fields; replace each guard, e.g.
if not self._dns_set: → if not self.dns.present: (same for connect/tls).
Keeps the redirect first-hop-wins semantics documented at probe.py:114–123.
5. De-duplicate the failure counting logic
File: hxprobe/hxprobe/cli.py
The identical "dedupe failures into ordered (phase, message, count)" loop appears
twice: _print_failure_summary (lines 242–258) and _build_json_entry
(lines 314–325). Extract one helper next to the other rendering helpers:
def _summarize_failures(failed: list[Result]) -> list[tuple[str, str, int]]:
counts: dict[tuple[str, str], int] = {}
order: list[tuple[str, str]] = []
for r in failed:
key = (r.fail_phase, str(r.err))
if key not in counts:
order.append(key)
counts[key] = 0
counts[key] += 1
return [(ph, msg, counts[(ph, msg)]) for ph, msg in order]
Rewrite both call sites to consume it (text side keeps the 1 × vs N ×
formatting; JSON side maps to {"phase", "count", "message"}).
Explicitly NOT changing (considered, kept)
- The custom httpcore backend (
_TimingBackend/_TimingStream/_TimingTransport) — this is the tool's reason to exist (splitting DNS/TCP, timing TLS). Not scaffolding. ThreadPoolExecutorconcurrency — backs the shipped-c/--concurrencyand multi-URL/-ffeatures. Real, not speculative.- Explicit per-phase dataclass fields in
probe.py/aggregate.py— a loop would be shorter but less readable; explicit is clearer and matches the text/ JSON renderers. Leave as-is. CertInfo.sans— not shown in the text block but is emitted in JSON verbose output; it's a real feature, not dead.
Verification
-
Unit/CLI tests (primary contract):
cd hxprobe && .venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -qAll must pass with no edits to the test files. These already cover:
-hhelp→stdout + exit 0, the four usage errors→stderr + exit 1, DNS/connect/ timeout failures,--fail,--json, redirects,--no-http2,--no-follow-redirects,-ffile input, and verbose/cert/TLS detail. -
Lint:
cd hxprobe && .venv/bin/ruff check hxprobe/ -
Manual smoke (help + error routing, since #1 rewrites that path):
.venv/bin/python -m hxprobe -h # help on stdout, exit 0 .venv/bin/python -m hxprobe # "no URLs given" on stderr, exit 1 .venv/bin/python -m hxprobe https://example.com -v # phases + verbose block -
Per-CLAUDE.md project conventions: after implementing, add a
docs/summaries/<yyyy-mm-dd-hh-mm>-hxprobe-simplification.mdsummary and aCHANGELOG.mdentry. (Nodocs/usage/change — behavior is unchanged.)