docs: hxprobe plans, summaries, explanations, usage, and changelog
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>
This commit is contained in:
130
docs/plans/2026-07-01-23-47-py-hxprobe-httpx.md
Normal file
130
docs/plans/2026-07-01-23-47-py-hxprobe-httpx.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Plan: `hxprobe` — httpx-based Python probe (Go-client parity)
|
||||
|
||||
## Context
|
||||
|
||||
The existing Python `latprobe` package measures per-phase HTTP latency with raw
|
||||
sockets. That gives an excellent DNS/TCP/TLS/TTFB/Transfer breakdown, but the
|
||||
cost is that it always speaks **HTTP/1.1** and **does not follow redirects** —
|
||||
diverging from the Go implementation, whose `http.DefaultClient` negotiates
|
||||
**HTTP/2** via ALPN, **follows redirects** (up to 10), pools connections, and
|
||||
verifies TLS by default (confirmed: `go/internal/probe/probe.go` uses
|
||||
`http.DefaultClient.Do` with no custom transport/`CheckRedirect`).
|
||||
|
||||
Goal: add a **second Python implementation, `hxprobe`**, built on the `httpx`
|
||||
library so it matches the Go client's protocol capabilities (HTTP/2, redirects,
|
||||
pooling, TLS verification) **while preserving the full 6-phase timing** that is
|
||||
latprobe's whole point. Python has no equivalent of Go's `net/http/httptrace`,
|
||||
so the phase breakdown is recovered by instrumenting httpx's network backend.
|
||||
|
||||
This is an **additive, Python-only experiment** — intentionally outside the
|
||||
CLAUDE.md "Go first, then Python port" flow, since the user explicitly asked for
|
||||
a library-based Python variant. No Go change is required.
|
||||
|
||||
## Approach
|
||||
|
||||
New sibling package `python/hxprobe/`, reusing everything reusable from
|
||||
`latprobe` (dataclasses, aggregation, duration parsing, CLI rendering) so the
|
||||
only genuinely new code is the httpx probe backend.
|
||||
|
||||
### Key design: instrumented httpx transport
|
||||
|
||||
`httpx` (sync `httpx.Client`) runs on `httpcore`. To recover per-phase timing we
|
||||
subclass httpcore's sync network backend and time the phases at the socket
|
||||
level, letting httpx own HTTP framing, HTTP/2, redirects, and keep-alive:
|
||||
|
||||
- `connect_tcp(...)` — reimplement DNS + TCP as separate steps (port the
|
||||
`socket.getaddrinfo` → `socket.connect` split already in
|
||||
`latprobe/probe.py:167-203`), timestamping **DNS** and **TCP connect**
|
||||
independently, and capturing the resolved IP.
|
||||
- `start_tls(...)` — timestamp the **TLS handshake**; pull negotiated version /
|
||||
cipher / peer cert from the SSL object for verbose mode.
|
||||
- **TTFB** = headers-received minus end-of-TLS (server processing), measured via
|
||||
`client.stream("GET", ...)` (the `stream()` context yields once response
|
||||
headers arrive).
|
||||
- **Transfer** = iterating `resp.iter_raw()` to EOF, minus headers-received.
|
||||
- **Total** = wraps the whole `measure()` call.
|
||||
|
||||
Each `measure()` call uses a **fresh `httpx.Client` (no cross-sample pooling)** so
|
||||
every `-n` sample yields a full phase breakdown — matching the current
|
||||
raw-socket `latprobe` behavior rather than Go's pool-reuse quirk.
|
||||
|
||||
A per-call trace object (held by the backend instance) records timings and the
|
||||
`fail_phase` at the exact point a phase raises, giving precise error
|
||||
classification (`dns`/`connect`/`timeout`/`tls`/`transfer`/`request`) without
|
||||
guessing from httpx exception types.
|
||||
|
||||
**Redirects (followed by default, Go parity):** DNS/connect/TLS are reported
|
||||
from the **first** connection (mirrors Go's `connectStart.IsZero()` guard);
|
||||
TTFB/Transfer/Total span the full followed chain. `redirect_count` and the
|
||||
negotiated `http_version` (`h2` vs `http/1.1`) are surfaced as new verbose
|
||||
fields — a genuine capability the socket version lacks.
|
||||
|
||||
## Files
|
||||
|
||||
**New:**
|
||||
- `python/pyproject.toml` — project metadata; dependency `httpx[http2]` (pulls
|
||||
`h2`). Makes `latprobe` + `hxprobe` `pip install -e .`-able; tests still run
|
||||
via `PYTHONPATH`.
|
||||
- `python/hxprobe/__init__.py`
|
||||
- `python/hxprobe/probe.py` — `measure(url, opts) -> latprobe.probe.Result`
|
||||
(imports & returns the **same `Result`** so aggregation/rendering just work);
|
||||
`_TimingBackend`, the per-call trace, error classification, verbose capture.
|
||||
- `python/hxprobe/cli.py` — thin: delegates to `latprobe.cli.run(...)` passing
|
||||
`measure_fn=hxprobe.probe.measure` (see reuse edit below).
|
||||
- `python/hxprobe/__main__.py` — `sys.exit(cli.run(sys.argv[1:], ...))`.
|
||||
- `python/tests/test_hx_probe.py` — hermetic, local `http.server`: phase
|
||||
presence, **redirect following** (302 handler — validates the Go-parity
|
||||
feature), connection-refused → `connect`, black-hole port → `timeout`.
|
||||
- `python/tests/test_hx_cli.py` — hermetic CLI via `run()` with `io.StringIO`
|
||||
(mirrors `tests/test_cli.py:73-76`).
|
||||
- `python/tests/test_integration_hx.py` — live, **excluded from default gate**
|
||||
(filename starts `test_i…`, so the `test_[!i]*.py` glob skips it): probe a
|
||||
real HTTP/2 host and assert negotiated `http_version == "h2"`; guard with a
|
||||
`@skipUnless(_online())` like `tests/test_integration.py:29-40`.
|
||||
- `docs/usage/py-hxprobe.md` — usage doc (what it does, flags, example with
|
||||
expected output, and an explicit socket-vs-httpx capability comparison table),
|
||||
per CLAUDE.md.
|
||||
|
||||
**Edited (small, backward-compatible):**
|
||||
- `python/latprobe/cli.py` — parameterize `run()` and `_run_samples()` with an
|
||||
injectable `measure_fn` (default = current `latprobe.probe.measure`), so
|
||||
`hxprobe` reuses all argparse, concurrency, exit-code, text/JSON rendering
|
||||
logic. Extend `_print_verbose_block` and `_build_json_entry` to show
|
||||
`http_version` / `redirect_count` **when present** (existing socket path never
|
||||
sets them → output unchanged).
|
||||
- `python/latprobe/probe.py` — add optional fields with safe defaults:
|
||||
`Options.follow_redirects=True`, `Options.http2=True` (ignored by the socket
|
||||
measure); `VerboseDetail.http_version=""`, `VerboseDetail.redirect_count=0`.
|
||||
- `Makefile` — add `py-deps` (create `.venv`, `pip install -e python`),
|
||||
`hx-run` (`cd python && python -m hxprobe $(ARGS)`), and fold `test_hx_*` into
|
||||
the existing `py-test` gate; add `hx-test-integration` for the live h2 check.
|
||||
- `CHANGELOG.md` — append a timestamped one-line entry on completion.
|
||||
|
||||
**Reused as-is:** `latprobe.aggregate.summarize`,
|
||||
`latprobe.duration.parse_duration`, `latprobe.probe.{Result,Phase,CertInfo}`,
|
||||
and the entire `latprobe.cli` renderer via the `measure_fn` injection.
|
||||
|
||||
## Flags / behavior
|
||||
|
||||
Same surface as `latprobe` (`-n/--count`, `-c/--concurrency`, `--timeout`,
|
||||
`--fail`, `--json`, `-v/--verbose`) via the reused parser, plus two new opt-outs
|
||||
for the Go-like defaults: `--no-http2` and `--no-follow-redirects`. Exit codes
|
||||
0–6 stay identical to Go/`latprobe`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make py-deps` — create venv, install `httpx[http2]`.
|
||||
2. `make hx-run ARGS="https://example.com"` — full 6-phase text output renders.
|
||||
3. `make hx-run ARGS="-v https://www.cloudflare.com"` — verbose block shows
|
||||
`http_version: h2` and TLS/cert details.
|
||||
4. **Go-parity spot checks:**
|
||||
- HTTP/2: `python -m hxprobe -v <h2-host>` reports `h2` where
|
||||
`python -m latprobe -v <h2-host>` reports HTTP/1.1.
|
||||
- Redirects: `python -m hxprobe http://github.com` follows to https and shows
|
||||
`redirect_count > 0` (socket `latprobe` shows a raw 301).
|
||||
5. `make py-test` — hermetic suite (now including `test_hx_probe.py`,
|
||||
`test_hx_cli.py`) is green; `latprobe`'s existing tests still pass (proves the
|
||||
`measure_fn`/`Options`/`VerboseDetail` edits are backward-compatible).
|
||||
6. `make hx-test-integration` — live test confirms real `h2` negotiation.
|
||||
7. Confirm `--json` output for `hxprobe` matches the `latprobe` schema plus the
|
||||
optional `verbose.http_version` / `verbose.redirect_count` keys.
|
||||
151
docs/plans/2026-07-02-09-32-hxprobe-standalone-project.md
Normal file
151
docs/plans/2026-07-02-09-32-hxprobe-standalone-project.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Plan: extract `hxprobe` into a fully standalone top-level project
|
||||
|
||||
## Context
|
||||
|
||||
`hxprobe` currently lives at `python/hxprobe/` and imports shared code from
|
||||
`latprobe` (`latprobe.probe.{Options,Phase,Result,VerboseDetail,_parse_cert}`,
|
||||
`latprobe.cli.run`). That coupling was a deliberate reuse choice at the time,
|
||||
but the user now wants `hxprobe` to be a genuinely independent project — no
|
||||
`from latprobe import ...` anywhere, duplication accepted — and, per their
|
||||
follow-up answer, independent enough that it could be `cp -r`'d into its own
|
||||
repo tomorrow: own `pyproject.toml`, own venv, own Makefile section, own
|
||||
top-level directory (sibling to `go/` and `python/`), not nested under `python/`.
|
||||
|
||||
Confirmed via `git status`/`git log`: everything hxprobe-related
|
||||
(`python/hxprobe/`, `python/pyproject.toml`, `python/tests/test_hx_*.py`,
|
||||
`docs/usage/py-hxprobe.md`, the two `docs/plans`/`docs/summaries` entries) is
|
||||
still uncommitted from this session, and `python/latprobe/cli.py` /
|
||||
`python/latprobe/probe.py` only diverge from the last commit (`24ea9c9`) by
|
||||
the reuse-oriented additions made to support hxprobe. So this is a clean,
|
||||
low-risk restructuring: move already-debugged code, revert latprobe with
|
||||
`git checkout --`, no git history surgery needed.
|
||||
|
||||
## Target layout
|
||||
|
||||
```
|
||||
hxprobe/ (new, top-level, sibling of go/ and python/)
|
||||
├── pyproject.toml (own manifest: httpx[http2] dependency)
|
||||
├── hxprobe/
|
||||
│ ├── __init__.py
|
||||
│ ├── probe.py (own Options/Phase/Result/VerboseDetail/CertInfo
|
||||
│ │ + own _parse_cert/_parse_cert_date + existing
|
||||
│ │ _Trace/_TimingStream/_TimingBackend/
|
||||
│ │ _TimingTransport/measure() — unchanged logic)
|
||||
│ ├── aggregate.py (verbatim copy of latprobe/aggregate.py —
|
||||
│ │ its `from .probe import Result` is already
|
||||
│ │ package-relative, needs zero edits)
|
||||
│ ├── duration.py (verbatim copy of latprobe/duration.py — no
|
||||
│ │ imports at all)
|
||||
│ ├── cli.py (full standalone CLI — see below)
|
||||
│ └── __main__.py (unchanged: `from .cli import run`)
|
||||
└── tests/
|
||||
├── __init__.py (empty, matches python/tests/__init__.py)
|
||||
├── test_probe.py (moved from python/tests/test_hx_probe.py)
|
||||
├── test_cli.py (moved from python/tests/test_hx_cli.py)
|
||||
└── test_integration.py (moved from python/tests/test_integration_hx.py)
|
||||
```
|
||||
|
||||
`python/` reverts to containing only `latprobe` — zero third-party deps, no
|
||||
`pyproject.toml`, no venv, exactly its pre-hxprobe state.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
**1. Move already-debugged files (preserve the bug fixes already made):**
|
||||
`git mv`/`mv` (untracked, so plain `mv` is fine) `python/hxprobe/{probe.py,__init__.py,__main__.py}`
|
||||
to `hxprobe/hxprobe/`, and the three `python/tests/test_hx_*.py` /
|
||||
`test_integration_hx.py` files to `hxprobe/tests/` with the `hx_`/`_hx` name
|
||||
segments dropped (`test_probe.py`, `test_cli.py`, `test_integration.py`).
|
||||
Do **not** rewrite these from scratch — they already have the mark_dns /
|
||||
verbose-on-failure-path / Content-Length-on-keep-alive fixes found during
|
||||
the original implementation.
|
||||
|
||||
**2. Inline the dataclasses into `hxprobe/hxprobe/probe.py`:**
|
||||
Replace `from latprobe.probe import Options, Phase, Result, VerboseDetail, _parse_cert`
|
||||
with local definitions copied verbatim from `python/latprobe/probe.py`:
|
||||
`CertInfo`, `VerboseDetail` (its `http_version`/`redirect_count` fields are
|
||||
now simply always-meaningful, no more "populated only by hxprobe" caveat
|
||||
comment needed), `Options` (with `follow_redirects`/`http2` as normal fields,
|
||||
no more "ignored by socket measure()" caveat), `Phase`, `Result`,
|
||||
`_parse_cert`, `_parse_cert_date`. Everything else in the file (`_Trace`,
|
||||
`_TimingStream`, `_TimingBackend`, `_TimingTransport`, `measure()`,
|
||||
`_classify`, `_unwrap`, `_fill_phases`, `_fill_verbose`) is untouched.
|
||||
|
||||
**3. Create `hxprobe/hxprobe/aggregate.py` and `duration.py`:**
|
||||
Verbatim copies of `python/latprobe/aggregate.py` and `duration.py`.
|
||||
|
||||
**4. Write `hxprobe/hxprobe/cli.py` as a full standalone CLI:**
|
||||
Start from the *current* `python/latprobe/cli.py` (it already has 100% of
|
||||
the needed logic, including the `--no-http2`/`--no-follow-redirects` flags,
|
||||
the verbose "Protocol" row, and the JSON `http_version`/`redirect_count`
|
||||
keys — all added earlier specifically for hxprobe). Strip the
|
||||
generalization scaffolding that only existed to let `latprobe` share this
|
||||
code:
|
||||
|
||||
- Remove the `MeasureFn` type alias and the `measure_fn` parameter from
|
||||
`_run_samples`/`run` — call `measure` directly (module-level import from
|
||||
`.probe`).
|
||||
- Remove `run()`'s `prog`/`description`/`protocol_flags` parameters —
|
||||
hardcode `prog="hxprobe"` and the httpx-specific description.
|
||||
- Make the `--no-http2`/`--no-follow-redirects` `add_argument` calls
|
||||
unconditional (drop the `if protocol_flags:` guard).
|
||||
- Everything else (exit codes, `_ArgExit`/`_Parser`, phase-label/verbose
|
||||
constants, all `_print_*`/`_build_json_entry` rendering) carries over
|
||||
unchanged — it's already correct standalone logic.
|
||||
|
||||
**5. Revert `python/latprobe/` to its pre-hxprobe state:**
|
||||
`git checkout -- python/latprobe/cli.py python/latprobe/probe.py` (safe:
|
||||
confirmed these are the only diffs since the last commit, and both diffs
|
||||
are exactly the reuse scaffolding being removed here).
|
||||
|
||||
**6. Clean up the old shared-package artifacts:**
|
||||
Delete `python/hxprobe/`, `python/pyproject.toml`, `python/.venv/`, and the
|
||||
three `python/tests/test_hx_*`/`test_integration_hx.py` files (now moved).
|
||||
|
||||
**7. Makefile:**
|
||||
- Remove `PY_VENV`/`PY_VENV_PYTHON` vars and the `py-deps` target; revert
|
||||
`py-test`/`py-test-integration`/`py-check` to invoke `$(PYTHON)` directly
|
||||
with no `py-deps` prerequisite (their pre-hxprobe form). Remove `hx-run`/
|
||||
`hx-test-integration` from the Python section (moving out).
|
||||
- Add `HX_DIR := hxprobe`, `HX_VENV := $(HX_DIR)/.venv`,
|
||||
`HX_VENV_PYTHON := $(HX_VENV)/bin/$(PYTHON)` and a new "── hxprobe
|
||||
(standalone) ──" section: `hx-deps` (idempotent venv+pip install, mirrors
|
||||
the removed `py-deps`), `hx-run`, `hx-test` (hermetic, `test_[!i]*.py`
|
||||
glob), `hx-test-integration` (`test_integration.py` pattern), `hx-check`,
|
||||
`hx-clean`.
|
||||
- Fold into the umbrella targets: `test: go-test py-test hx-test`,
|
||||
`check: go-check py-check hx-check`, `clean: go-clean py-clean hx-clean`.
|
||||
|
||||
**8. Docs:**
|
||||
- Rename `docs/usage/py-hxprobe.md` → `docs/usage/hxprobe.md` (drop the
|
||||
`py-` prefix — it's no longer part of the Python port). Update: remove the
|
||||
"reuses `latprobe.cli.run()` in full" claim (now false — say it's a fully
|
||||
standalone implementation sharing only the *design*, not the code, with
|
||||
`latprobe`); update setup/Makefile-target sections to `make hx-deps`/
|
||||
`hx-run`/`hx-test`/`hx-test-integration`; update file paths from
|
||||
`python/hxprobe/` to `hxprobe/hxprobe/`. Keep the TCP_NODELAY/Nagle finding
|
||||
and the example transcripts (still accurate — behavior is unchanged, only
|
||||
location/packaging changed).
|
||||
- `CHANGELOG.md`: new entry describing the extraction.
|
||||
- `docs/summaries/`: new dated summary per the CLAUDE.md convention
|
||||
(leave the original `2026-07-02-00-29-py-hxprobe-httpx.md` as-is — it's a
|
||||
historical record of that implementation; this is a follow-up).
|
||||
- `docs/plans/`: save this plan as
|
||||
`docs/plans/<yyyy-mm-dd-hh-mm>-hxprobe-standalone-project.md` at
|
||||
implementation start, per CLAUDE.md.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `rm -rf hxprobe/.venv python/.venv` (fresh state) then `make hx-deps` —
|
||||
creates `hxprobe/.venv`, installs `httpx[http2]` from `hxprobe/pyproject.toml`.
|
||||
2. `grep -rn "latprobe" hxprobe/` — must return nothing (proves the
|
||||
decoupling).
|
||||
3. `make hx-run ARGS="-v http://github.com"` — same output as before (HTTP/2,
|
||||
1 redirect followed, IP/TLS/cert shown).
|
||||
4. `make hx-test` — all hermetic hxprobe tests pass standalone.
|
||||
5. `make hx-test-integration` — live HTTP/2 + redirect tests still pass.
|
||||
6. `make py-test` — confirms `latprobe`'s own suite is back to its original,
|
||||
dependency-free form and still green (no `py-deps` needed to run it).
|
||||
7. `make check` (top-level) — Go + latprobe + hxprobe all green in one gate.
|
||||
8. `diff <(python -m latprobe --help) <(git show 24ea9c9:python/latprobe/cli.py | ...)` —
|
||||
or simpler: confirm `python -m latprobe --help` output is byte-identical
|
||||
to before this whole feature existed (no leftover `--no-http2` etc.).
|
||||
115
docs/plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md
Normal file
115
docs/plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Plan: modernize hxprobe's Python toolchain
|
||||
|
||||
## Context
|
||||
|
||||
`hxprobe` (`hxprobe/`) is a fully standalone Python project (own
|
||||
`pyproject.toml`, own venv, own tests — see
|
||||
`docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md`). Its
|
||||
toolchain is currently bare-minimum: hand-rolled `python -m venv` + `pip
|
||||
install -e .`, no linter/formatter, no type checker, no dependency lockfile,
|
||||
and stdlib `unittest` with a filename-glob convention
|
||||
(`test_[!i]*.py`/`test_integration.py`) to separate hermetic from live tests.
|
||||
|
||||
User confirmed direction (via AskUserQuestion): adopt **uv** for env/deps
|
||||
(with a lockfile), add **ruff** for lint+format, **skip mypy** for now, and
|
||||
**swap the test runner to pytest** (existing `unittest.TestCase` classes run
|
||||
unchanged under pytest — no test-code rewrite) using a proper
|
||||
`@pytest.mark.integration` marker instead of the filename-glob trick.
|
||||
|
||||
Confirmed via inspection: `uv` (v0.11.19) is already installed globally
|
||||
(Homebrew); `ruff`/`mypy`/`pytest` only exist under an unrelated pyenv 3.12.3
|
||||
shim, not on the 3.14 interpreter this project targets — `uv` sidesteps that
|
||||
mismatch by installing everything into the project's own venv. Git remote is
|
||||
a self-hosted Gitea instance, not GitHub, so no CI is being set up here.
|
||||
|
||||
## Changes
|
||||
|
||||
**`hxprobe/pyproject.toml`:**
|
||||
- Add `[dependency-groups]` with `dev = ["pytest>=8.0", "ruff>=0.8"]` (PEP
|
||||
735, the current uv-native way to declare dev-only deps — keeps the
|
||||
install-as-a-library `dependencies` list clean).
|
||||
- Add `[tool.pytest.ini_options]`: `testpaths = ["tests"]` and a registered
|
||||
`integration` marker (avoids `PytestUnknownMarkWarning`).
|
||||
- Add `[tool.ruff]`: `target-version = "py311"` (matches `requires-python`)
|
||||
and `line-length = 100` (close to the codebase's existing longest lines,
|
||||
~103 chars, to minimize reformatting churn).
|
||||
- Leave `[build-system]`/`[tool.setuptools]` untouched — build backend
|
||||
wasn't part of the discussion, setuptools works fine here.
|
||||
|
||||
**`hxprobe/.python-version`:** new file, `3.14`, so `uv sync`/`uv run` pin the
|
||||
same interpreter the rest of the repo uses without relying on `$PATH` order.
|
||||
|
||||
**`hxprobe/uv.lock`:** generated by `uv lock` — first real lockfile pinning
|
||||
`httpx`, `httpcore`, `h2`, `certifi`, and friends. Committed (not gitignored;
|
||||
lockfiles belong in version control).
|
||||
|
||||
**Test changes (runner swap only, no rewrite):**
|
||||
- `hxprobe/tests/test_integration.py`: add `pytestmark = pytest.mark.integration`
|
||||
at module level. This replaces the "named `test_integration.py` so the
|
||||
`test_[!i]*.py` glob skips it" convention — selection becomes `-m
|
||||
"not integration"` / `-m integration`, independent of filename.
|
||||
- `test_probe.py`/`test_cli.py`: no changes — they're already hermetic
|
||||
(local `http.server` fixtures only) and need no marker.
|
||||
- `unittest.TestCase` classes, `_NEEDS_NET = unittest.skipUnless(...)`, and
|
||||
the `if __name__ == "__main__": unittest.main()` guards all stay exactly
|
||||
as-is — pytest natively discovers and runs unittest-style tests and
|
||||
respects `unittest.skip*` decorators with zero changes required.
|
||||
|
||||
**Lint fixes:** run `ruff check --fix` / `ruff format` over `hxprobe/` and
|
||||
review the diff. One known pre-existing issue it will flag: `import sys` in
|
||||
`cli.py` is unused (inherited from the original `latprobe/cli.py`) — remove
|
||||
it. Otherwise expect mostly whitespace/quote-style normalization.
|
||||
|
||||
**`Makefile`:** replace the `hx-*` section to run through `uv` instead of a
|
||||
hand-managed venv:
|
||||
```makefile
|
||||
HX_DIR := hxprobe # (drop HX_VENV / HX_VENV_PYTHON — uv owns this now)
|
||||
|
||||
hx-deps: cd $(HX_DIR) && uv sync
|
||||
hx-run: (deps: hx-deps) cd $(HX_DIR) && uv run python -m hxprobe $(ARGS)
|
||||
hx-lint: (deps: hx-deps) cd $(HX_DIR) && uv run ruff check .
|
||||
hx-fmt: (deps: hx-deps) cd $(HX_DIR) && uv run ruff format .
|
||||
hx-test: (deps: hx-deps) cd $(HX_DIR) && uv run pytest tests -m "not integration" -v $(ARGS)
|
||||
hx-test-integration: (deps: hx-deps) cd $(HX_DIR) && uv run pytest tests -m integration -v $(ARGS)
|
||||
hx-check: hx-lint hx-test (test gate now includes lint, mirroring go-check's fmt+vet+test bundling)
|
||||
hx-clean: also removes .pytest_cache / .ruff_cache alongside __pycache__/egg-info
|
||||
```
|
||||
Umbrella `test`/`check`/`clean` targets keep delegating to `hx-test`/
|
||||
`hx-check`/`hx-clean` unchanged.
|
||||
|
||||
**`hxprobe/README.md`:** new, minimal — since this project is meant to be
|
||||
`cp -r`-able to its own repo, it should carry its own quick-start
|
||||
(`uv sync`, `uv run python -m hxprobe <url>`, `uv run pytest`) rather than
|
||||
relying on the monorepo's root docs.
|
||||
|
||||
**Docs:** update `docs/usage/hxprobe.md`'s "Setup" section (`uv sync`
|
||||
instead of manual venv+pip) and "Makefile targets" section (add
|
||||
`hx-lint`/`hx-fmt`, update test invocation description). New
|
||||
`docs/plans/<timestamp>-hxprobe-toolchain-modernization.md` and
|
||||
`docs/summaries/<timestamp>-hxprobe-toolchain-modernization.md` per
|
||||
CLAUDE.md convention. New `CHANGELOG.md` entry.
|
||||
|
||||
**`.gitignore`:** already covers `.venv/`/`__pycache__/`/`*.egg-info/`
|
||||
unanchored; add `.pytest_cache/` and `.ruff_cache/` (new caches these tools
|
||||
create).
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd hxprobe && uv sync` — creates `.venv`, generates/uses `uv.lock`,
|
||||
installs `httpx[http2]` + dev deps (`pytest`, `ruff`).
|
||||
2. `make hx-run ARGS="-v http://github.com"` — same output as before
|
||||
(HTTP/2, 1 redirect, IP/TLS/cert shown) — confirms the runtime behavior
|
||||
is untouched by the toolchain swap.
|
||||
3. `make hx-lint` — clean (after fixing whatever `ruff check` surfaces,
|
||||
including the unused `import sys`).
|
||||
4. `make hx-test` — all hermetic tests pass under pytest; confirm the
|
||||
`integration`-marked tests are excluded (test count matches the current
|
||||
28 hermetic tests).
|
||||
5. `make hx-test-integration` — the 9 live tests run and pass under the
|
||||
`integration` marker selection.
|
||||
6. `make check` (top-level) — Go + latprobe + hxprobe (lint + test) all
|
||||
green in one gate.
|
||||
7. `grep -rn "latprobe" hxprobe/` — still zero matches (toolchain change
|
||||
must not reintroduce coupling).
|
||||
8. Confirm `python/latprobe/` is untouched (`git diff --stat python/` empty)
|
||||
— this is a hxprobe-only change.
|
||||
112
docs/plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md
Normal file
112
docs/plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Plan: hxprobe-specific usage doc (case-by-case) + hxprobe-specific Makefile
|
||||
|
||||
## Context
|
||||
|
||||
`hxprobe` is a fully standalone project (own `pyproject.toml`, `uv.lock`,
|
||||
`README.md` — see `docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md`
|
||||
and `...-toolchain-modernization.md`). Two gaps remain versus the other
|
||||
Python implementations and versus hxprobe's own "could be `cp -r`'d to its
|
||||
own repo" design goal:
|
||||
|
||||
1. **Docs**: `latprobe`/`phases.py`/`simple.py` each have two usage docs —
|
||||
the CLAUDE.md-mandated `docs/usage/py-<name>.md` (what/flags/one example)
|
||||
_and_ a much richer `python/configs/usage-<name>.md` "Runnable Usage
|
||||
Reference" walking through a full set of concrete cases with real
|
||||
captured output (confirmed by reading `python/configs/usage-latprobe.md`,
|
||||
313 lines: basic, verbose × 4 variants, sampling, multi-URL, `--fail`,
|
||||
JSON × 2, timeout, exit-codes table, Makefile shortcuts). `hxprobe` only
|
||||
has the first kind. User confirmed: add the second kind, placed _inside_
|
||||
`hxprobe/` itself (not under `python/configs/`, since hxprobe no longer
|
||||
lives there) so the doc travels with the project if extracted.
|
||||
2. **Makefile**: hxprobe currently has no `Makefile` of its own — the only
|
||||
way to run/test/lint it is through the parent repo's root `Makefile`.
|
||||
Extracted to its own repo, there'd be no `make` interface left. User
|
||||
confirmed: add `hxprobe/Makefile`, fully independent from the root
|
||||
Makefile's existing `hx-*` targets (no delegation either direction —
|
||||
both keep their own complete logic, at the cost of some duplication).
|
||||
|
||||
Confirmed: neither `latprobe` nor `hxprobe` accept a config file (both take
|
||||
URLs as positional CLI args — only `simple.py`/`phases.py` read the
|
||||
`python/configs/*.txt` files), so no `.txt`-config-file equivalent is needed
|
||||
for hxprobe; this is a docs+Makefile-only task.
|
||||
|
||||
## Changes
|
||||
|
||||
**`hxprobe/USAGE.md`** (new) — modeled directly on
|
||||
`python/configs/usage-latprobe.md`'s structure and tone (concrete `sh`
|
||||
command blocks immediately followed by real captured output, real IPs/certs/
|
||||
timings, brief explanatory notes, `---` section separators). Cases, in order:
|
||||
|
||||
1. Basic — single URL
|
||||
2. Verbose — HTTPS site (shows `Protocol: HTTP/2`, TLS, cert)
|
||||
3. Verbose — plain HTTP (no TLS block)
|
||||
4. Verbose — redirect followed by default (`http://github.com` → 200,
|
||||
`Protocol: HTTP/2 (1 redirect)`) — **hxprobe-specific**, latprobe has no
|
||||
equivalent
|
||||
5. `--no-follow-redirects` — same URL, raw `301` instead — **hxprobe-specific**
|
||||
6. `--no-http2` — forces `Protocol: HTTP/1.1` — **hxprobe-specific**
|
||||
7. Verbose — TLS failure (expired cert, badssl.com)
|
||||
8. Verbose — DNS failure (empty verbose block, suppressed)
|
||||
9. Sampling (`-n`) — min/avg/max table, plus verbose+sampling
|
||||
10. Multiple URLs (parallel probing)
|
||||
11. `--fail` flag — exit 6 on HTTP 4xx
|
||||
12. JSON output
|
||||
13. JSON + verbose (includes `http_version`/`redirect_count` keys)
|
||||
14. Timeout
|
||||
15. Exit codes table (same 0–6 scheme as `latprobe`/Go)
|
||||
16. Makefile shortcuts — both the new `hxprobe/Makefile` (`make run`,
|
||||
`make test`, etc., run from inside `hxprobe/`) and the parent repo's
|
||||
root shortcuts (`make hx-run`, run from the repo root)
|
||||
|
||||
Cases 1, 2, 3, 4, 5, 6, 8, 11 can reuse real output already captured earlier
|
||||
in this session (still accurate — no code changed since). Cases 7, 9, 10,
|
||||
12, 13, 14 need fresh live runs during implementation to get real numbers
|
||||
(same standard the other `usage-*.md` docs hold themselves to — no
|
||||
fabricated timings).
|
||||
|
||||
**`docs/usage/hxprobe.md`** (edit) — add a one-line pointer near the top:
|
||||
"For a full case-by-case runnable reference, see `hxprobe/USAGE.md`." No
|
||||
other changes; it stays the CLAUDE.md-mandated summary doc.
|
||||
|
||||
**`hxprobe/Makefile`** (new) — fully self-sufficient, same auto-generated
|
||||
`## comment` help style as the root Makefile, short target names (no `hx-`
|
||||
prefix needed since it's already scoped by being inside `hxprobe/`):
|
||||
|
||||
```makefile
|
||||
PYTHON ?= python3.14
|
||||
ARGS ?=
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
help # auto-generated from ## comments, same style as root Makefile
|
||||
deps # uv sync
|
||||
run # uv run python -m hxprobe $(ARGS) (deps: deps)
|
||||
lint # uv run ruff check . (deps: deps)
|
||||
fmt # uv run ruff format . (deps: deps)
|
||||
test # uv run pytest tests -m "not integration" -v $(ARGS) (deps: deps)
|
||||
test-integration # uv run pytest tests -m integration -v $(ARGS) (deps: deps)
|
||||
check # lint + test
|
||||
clean # remove __pycache__/*.pyc/*.egg-info/.pytest_cache/.ruff_cache
|
||||
```
|
||||
|
||||
No changes to the root `Makefile` — its existing `hx-*` targets are left
|
||||
exactly as-is per the "keep both independent" decision.
|
||||
|
||||
**Docs housekeeping** (per CLAUDE.md convention): save this plan to
|
||||
`docs/plans/<timestamp>-hxprobe-usage-doc-and-makefile.md`, write a summary
|
||||
to `docs/summaries/<timestamp>-hxprobe-usage-doc-and-makefile.md` after
|
||||
implementation, and append a `CHANGELOG.md` entry.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd hxprobe && make help` — lists all targets with descriptions, works
|
||||
with zero dependency on the parent repo's Makefile.
|
||||
2. `cd hxprobe && make run ARGS="-v https://example.com"` — same output as
|
||||
`make hx-run ARGS="-v https://example.com"` from the repo root (proves
|
||||
the two Makefiles agree, without one calling the other).
|
||||
3. `cd hxprobe && make check` — lint + hermetic tests pass (28 tests).
|
||||
4. `cd hxprobe && make test-integration` — 9 live tests pass.
|
||||
5. Re-run every command block in `hxprobe/USAGE.md` and confirm the
|
||||
captured output matches what's printed in the doc (structure must be
|
||||
stable even if exact millisecond timings drift).
|
||||
6. Confirm the root `Makefile`'s `hx-*` targets are byte-for-byte unchanged
|
||||
(`git diff Makefile` shows no `hx-*` section changes from this task).
|
||||
102
docs/plans/2026-07-02-11-14-hxprobe-file-input.md
Normal file
102
docs/plans/2026-07-02-11-14-hxprobe-file-input.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Plan: read target URLs from a file for hxprobe
|
||||
|
||||
## Context
|
||||
|
||||
`hxprobe` currently only accepts URLs as positional CLI arguments
|
||||
(`hxprobe/hxprobe/cli.py:368`, `nargs="+"`). The user wants a file-based
|
||||
input mode too, matching the existing convention `simple.py`/`phases.py`
|
||||
already use (`python/simple.py:20-30`'s `load_sites()`: plain text, one URL
|
||||
per line, `#`-comments and blank lines skipped, first whitespace-separated
|
||||
token taken per line).
|
||||
|
||||
User-confirmed decisions:
|
||||
- **Mutually exclusive** with positional URL args (either pass URLs on the
|
||||
command line, or `-f FILE`, never both).
|
||||
- **hxprobe only** — `latprobe` is intentionally left untouched.
|
||||
- **Add example fixture files** (`hxprobe/configs/*.txt`, mirroring
|
||||
`python/configs/*.txt`'s exact set: all-ok, dns-failure,
|
||||
connection-refused, timeout, tls-errors, http-errors, mixed) plus one new
|
||||
section in the existing `hxprobe/USAGE.md` demonstrating the flag.
|
||||
|
||||
## Implementation
|
||||
|
||||
**`hxprobe/hxprobe/cli.py`:**
|
||||
- `urls` positional becomes `nargs="*"` (was `nargs="+"`) — no longer
|
||||
required on its own, since `-f` is now a second valid source.
|
||||
- New flag: `-f, --file PATH` — "read URLs from a file, one per line,
|
||||
`#` comments allowed (mutually exclusive with positional url args)".
|
||||
Placed right after the `urls` positional definition in the argparse
|
||||
block, since the two are the two ways of specifying what to probe.
|
||||
- New helper `_load_urls(path: str) -> list[str]`, duplicating (not
|
||||
importing) `simple.py`'s `load_sites()` logic — consistent with hxprobe's
|
||||
established "imports nothing outside its own directory" rule from the
|
||||
standalone-extraction work.
|
||||
- After `parser.parse_args()`, manual validation (mirrors the existing
|
||||
`--timeout` invalid-value handling style — write to the injected
|
||||
`stderr`, `return EXIT_USAGE`, rather than routing through
|
||||
`argparse`'s mutually-exclusive-group machinery, which doesn't mix
|
||||
cleanly with a variadic positional):
|
||||
- both `ns.urls` and `ns.file` given → `parser.error(...)` (usage error,
|
||||
consistent with how `_Parser.error()` already handles bad usage)
|
||||
- neither given → `parser.error(...)`
|
||||
- `ns.file` given but unreadable (`FileNotFoundError`/`OSError`) →
|
||||
`stderr.write(...)`; `return EXIT_USAGE`
|
||||
- `ns.file` given but yields zero URLs → same treatment
|
||||
- otherwise `urls = _load_urls(ns.file)` or `urls = ns.urls`
|
||||
|
||||
**`hxprobe/tests/test_cli.py`:** new hermetic tests — successful multi-URL
|
||||
run from a file, missing-file error, empty-file error, and the
|
||||
both-sources-given usage error. Uses a temp file (`tempfile`), no network
|
||||
needed for the parsing-error cases.
|
||||
|
||||
**`hxprobe/configs/*.txt`** (new directory) — same 7 fixtures as
|
||||
`python/configs/`, adapted:
|
||||
- `all-ok.txt`, `dns-failure.txt`, `connection-refused.txt`,
|
||||
`tls-errors.txt` — same URLs, same behavior (DNS/TCP/TLS failures are
|
||||
identical regardless of HTTP client sophistication); only the header
|
||||
comments change (`hxprobe -f configs/<name>.txt` instead of
|
||||
`python3.14 python/simple.py ...`).
|
||||
- `timeout.txt` — same two targets (`10.255.255.1`, `192.0.2.1`, RFC 5737
|
||||
TEST-NET-1) as the original; "expected exit code" documents normal-network
|
||||
behavior (exit 4), same caveat the original file already carries about
|
||||
network-dependent behavior.
|
||||
- `http-errors.txt` — same 404 URLs; header comment updated to show the
|
||||
demo command with `--fail` (hxprobe treats 4xx as success without
|
||||
`--fail`, unlike `simple.py`, which always raises on HTTPError) —
|
||||
"expected exit code" becomes 6, not `simple.py`'s blanket 1.
|
||||
- `mixed.txt` — same mixed set; demo command includes `--fail`; expected
|
||||
exit code recalculated as the worst code across the included classes
|
||||
(dns=2, connect=3, tls=5, http=6 with `--fail`) → 6.
|
||||
- Each header's "Expected exit code" will be verified by actually running
|
||||
the fixture through `hxprobe -f ...` during implementation, not assumed
|
||||
from the `simple.py` originals — hxprobe's worst-code-wins exit scheme
|
||||
(`hxprobe/hxprobe/cli.py:455-481`) differs fundamentally from
|
||||
`simple.py`'s blanket 0/1.
|
||||
|
||||
**`hxprobe/USAGE.md`:** new section "Reading URLs from a file (`-f`)",
|
||||
placed after the "Multiple URLs" case (same family of "what to probe"
|
||||
examples) — command + real captured output using `configs/all-ok.txt` or
|
||||
`configs/mixed.txt`, plus a short list of the other fixture files available
|
||||
and what each demonstrates.
|
||||
|
||||
**Docs housekeeping** (per CLAUDE.md convention): save this plan under
|
||||
`docs/plans/`, write a summary under `docs/summaries/` after implementation,
|
||||
append a `CHANGELOG.md` entry.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd hxprobe && uv run python -m hxprobe -f configs/all-ok.txt` — probes
|
||||
all 3 URLs, exit 0.
|
||||
2. `cd hxprobe && uv run python -m hxprobe -f configs/dns-failure.txt` —
|
||||
exit 2; `connection-refused.txt` → exit 3; `tls-errors.txt` → exit 5;
|
||||
`--fail -f configs/http-errors.txt` → exit 6; `--fail -f configs/mixed.txt`
|
||||
→ exit 6 (confirms the worst-code documented in each header is accurate).
|
||||
3. `uv run python -m hxprobe https://example.com -f configs/all-ok.txt` —
|
||||
usage error (both sources given).
|
||||
4. `uv run python -m hxprobe -f /no/such/file` — usage error, clear message.
|
||||
5. `cd hxprobe && make check` — new hermetic tests pass alongside the
|
||||
existing 28.
|
||||
6. Re-run the new `hxprobe/USAGE.md` section's command and confirm captured
|
||||
output matches what's printed in the doc.
|
||||
7. `grep -n "import" hxprobe/hxprobe/cli.py` — confirm no new import from
|
||||
`python/simple.py` or anywhere outside `hxprobe/`.
|
||||
174
docs/plans/2026-07-02-12-05-hxprobe-simplification.md
Normal file
174
docs/plans/2026-07-02-12-05-hxprobe-simplification.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 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.ArgumentParser` writes help to `sys.stdout` and usage/errors to
|
||||
`sys.stderr` by default, and already raises `SystemExit` (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 plain `argparse.ArgumentParser(prog="hxprobe", ...)` (drop
|
||||
the `out=`/`err=` kwargs).
|
||||
- Wrap the parse + the two manual validations in redirection and catch
|
||||
`SystemExit`:
|
||||
|
||||
```python
|
||||
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 to `stdout`, raises `SystemExit(0)` → returns
|
||||
`EXIT_OK`. (`test_help_shows_hxprobe_prog_name`, `test_help_lists_protocol_flags`)
|
||||
- `parser.error(...)` → prints `prog: error: msg` + usage to `stderr`, raises
|
||||
`SystemExit(2)` → mapped to `EXIT_USAGE`. (`test_*_is_usage_error`)
|
||||
- Note: stdlib `error()` exits with code 2; we normalise any non-zero parse exit
|
||||
to `EXIT_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:
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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.
|
||||
- **`ThreadPoolExecutor` concurrency** — backs the shipped `-c/--concurrency`
|
||||
and multi-URL/`-f` features. 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
|
||||
|
||||
1. **Unit/CLI tests (primary contract):**
|
||||
```
|
||||
cd hxprobe && .venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q
|
||||
```
|
||||
All must pass with no edits to the test files. These already cover: `-h`
|
||||
help→stdout + exit 0, the four usage errors→stderr + exit 1, DNS/connect/
|
||||
timeout failures, `--fail`, `--json`, redirects, `--no-http2`,
|
||||
`--no-follow-redirects`, `-f` file input, and verbose/cert/TLS detail.
|
||||
|
||||
2. **Lint:** `cd hxprobe && .venv/bin/ruff check hxprobe/`
|
||||
|
||||
3. **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
|
||||
```
|
||||
|
||||
4. **Per-CLAUDE.md project conventions:** after implementing, add a
|
||||
`docs/summaries/<yyyy-mm-dd-hh-mm>-hxprobe-simplification.md` summary and a
|
||||
`CHANGELOG.md` entry. (No `docs/usage/` change — behavior is unchanged.)
|
||||
136
docs/plans/2026-07-02-14-05-hxprobe-run-summary-footer.md
Normal file
136
docs/plans/2026-07-02-14-05-hxprobe-run-summary-footer.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# hxprobe: end-of-run summary footer for multi-URL runs
|
||||
|
||||
## Context
|
||||
|
||||
A single scalar exit code is inherently lossy when probing multiple URLs with
|
||||
different failure classes: today `run()` returns the numeric max across all
|
||||
URLs (e.g. DNS-fail on one URL + TLS-fail on another → exit `5`, and the DNS
|
||||
failure is invisible in the code). The failures *are* all printed per-URL, but
|
||||
there's no consolidated view — for many URLs you must scroll and eyeball each
|
||||
block to know what happened overall.
|
||||
|
||||
The exit code's job is a coarse pass/fail + severity hint for scripts, and it's
|
||||
a **deliberate cross-implementation contract** shared with `latprobe` and the
|
||||
Go version (documented in `hxprobe/USAGE.md:475-488`, asserted by 13 tests in
|
||||
`tests/test_cli.py`). So we keep the worst-code exit unchanged and instead give
|
||||
humans the full picture the scalar can't: an **end-of-run summary footer** that
|
||||
tallies every URL's outcome and shows how the exit code was derived.
|
||||
|
||||
Decisions (confirmed with the user):
|
||||
- Exit code: **unchanged** (worst/highest severity across URLs).
|
||||
- Summary: **text footer, multi-URL only** (`len(urls) > 1`). Single-URL text
|
||||
output stays byte-identical; JSON output stays a bare array (unchanged).
|
||||
|
||||
## Design
|
||||
|
||||
### Per-URL classification (reuses existing helpers)
|
||||
Each URL gets one "worst outcome code" using the *existing* mapping — no new
|
||||
severity scheme:
|
||||
- start at `EXIT_OK`;
|
||||
- for each failed sample: `code = max(code, _phase_code(r.fail_phase))`
|
||||
(`_phase_code` at `cli.py:31`);
|
||||
- if `--fail`: for each succeeded sample with `status_code >= 400`:
|
||||
`code = max(code, EXIT_HTTP)`.
|
||||
|
||||
A URL is "ok" iff its code is `EXIT_OK`, else "failed" and bucketed by its code.
|
||||
A partially-failed URL (some samples ok, some failed) classifies by its worst
|
||||
sample — consistent with how its own block and the global exit code already
|
||||
behave.
|
||||
|
||||
### Footer format (text, only when `len(urls) > 1`)
|
||||
Printed once after the last URL block, before `return worst`. Failure classes
|
||||
use the same `✗` bullet style as `_print_failure_summary` (`cli.py:219-224`).
|
||||
The final `→ exit N (label)` line explicitly ties the tally to the returned
|
||||
code — directly answering "why is the exit code what it is". Example (3 URLs):
|
||||
|
||||
```
|
||||
https://example.com (200)
|
||||
... phase rows ...
|
||||
|
||||
http://no.such.host.invalid (FAILED)
|
||||
✗ dns: [Errno 8] nodename nor servname provided
|
||||
|
||||
https://self-signed.badssl.com (FAILED)
|
||||
✗ tls: certificate verify failed
|
||||
|
||||
─────────────────────────────────────────────────
|
||||
Summary: 3 URLs — 1 ok, 2 failed
|
||||
✗ dns : 1
|
||||
✗ tls : 1
|
||||
→ exit 5 (tls)
|
||||
```
|
||||
|
||||
All-ok multi-URL run → `Summary: 3 URLs — 3 ok`, no `✗` lines, `→ exit 0 (ok)`.
|
||||
|
||||
## Changes
|
||||
|
||||
All in `hxprobe/hxprobe/cli.py` unless noted.
|
||||
|
||||
1. **Reverse label map** next to the exit-code constants (`cli.py:16-32`): a
|
||||
small `_EXIT_LABELS: dict[int, str]` mapping `EXIT_DNS→"dns"`,
|
||||
`EXIT_CONNECT→"connect"`, `EXIT_TIMEOUT→"timeout"`, `EXIT_TLS→"tls"`,
|
||||
`EXIT_HTTP→"http"`, `EXIT_OK→"ok"`. (Inverse of the existing forward mapping;
|
||||
kept explicit for readability, matching the codebase's style.)
|
||||
|
||||
2. **Refactor the accumulation loop** (`cli.py:454-462`) to compute a per-URL
|
||||
code and fold it into `worst`, collecting `url_codes: list[int]` (one per
|
||||
URL, index-aligned with `urls`). This also unifies the two "running max"
|
||||
idioms flagged in
|
||||
`docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`
|
||||
(`if c > worst` vs `max(...)`) into one — a small simplification bonus.
|
||||
|
||||
3. **New `_print_run_summary(urls, url_codes, worst, out)` helper** (near the
|
||||
other `_print_*` renderers): builds the ok/failed tally + per-class counts
|
||||
from `url_codes` and writes the footer. No-op guard is the caller's
|
||||
`len(urls) > 1` check.
|
||||
|
||||
4. **Call site** after the loop (`cli.py:470-475`): in text mode only
|
||||
(`if not ns.json_out and len(urls) > 1:`), call `_print_run_summary(...)`
|
||||
before `return worst`. JSON path untouched — still `json.dumps(json_items)`
|
||||
as a bare array.
|
||||
|
||||
## Tests (`hxprobe/tests/test_cli.py`)
|
||||
|
||||
Add hermetic tests (reuse the existing `_OKHandler`/`_start_server`/`_invoke`
|
||||
harness and `_free_port` for a refused connection):
|
||||
- **multi-URL mixed** — one OK server URL + one `http://127.0.0.1:<free>`
|
||||
(connection refused): assert `code == EXIT_CONNECT`, and the footer strings
|
||||
are present (`"Summary: 2 URLs"`, `"1 ok"`, `"1 failed"`, `"connect : 1"`,
|
||||
`"→ exit 3"`).
|
||||
- **multi-URL all ok** — two OK URLs: assert `code == EXIT_OK` and
|
||||
`"Summary: 2 URLs — 2 ok"` present; verify no `"✗"` in the footer region.
|
||||
- **single URL has NO footer** — one OK URL: assert `"Summary:"` NOT in `out`
|
||||
(locks the multi-URL-only rule).
|
||||
|
||||
Backward-compat guard: the footer contains no `"200"` substring, so the
|
||||
existing `test_reads_urls_from_file` assertion `out.count("200") == 2` still
|
||||
holds; all 13 existing exit-code tests are unaffected (worst-code unchanged).
|
||||
|
||||
## Docs / project conventions (per CLAUDE.md)
|
||||
|
||||
- **Copy this approved plan** into
|
||||
`docs/plans/<yyyy-mm-dd-hh-mm>-hxprobe-run-summary-footer.md` as the first
|
||||
implementation step (before code) — see the `feedback_plan_mode_docs_plans`
|
||||
memory note.
|
||||
- `hxprobe/USAGE.md`: add a short "Multi-URL summary footer" subsection with a
|
||||
real captured example; add a sentence to the Exit-codes section noting the
|
||||
footer shows the per-class breakdown behind the scalar. Exit-code table
|
||||
itself is unchanged.
|
||||
- `docs/usage/hxprobe.md`: one-line mention if it lists features.
|
||||
- New `docs/explanations/<ts>-hxprobe-run-summary-footer.md` is optional; the
|
||||
existing worst-exit-code explanation can get a short "Update:" pointer.
|
||||
- `CHANGELOG.md`: new timestamped entry.
|
||||
- `docs/summaries/<ts>-hxprobe-run-summary-footer.md`: implementation summary.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd hxprobe && .venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q`
|
||||
— all existing + new tests pass.
|
||||
2. `.venv/bin/ruff check hxprobe/` — clean.
|
||||
3. Manual, capturing exit codes:
|
||||
```
|
||||
.venv/bin/python -m hxprobe https://example.com https://example.org # footer, exit 0
|
||||
.venv/bin/python -m hxprobe https://example.com http://no.such.host.invalid; echo $? # footer w/ dns:1, exit 2
|
||||
.venv/bin/python -m hxprobe https://example.com # single URL: NO footer, unchanged
|
||||
.venv/bin/python -m hxprobe --json https://example.com https://example.org # bare JSON array, NO footer
|
||||
```
|
||||
Reference in New Issue
Block a user