# Plan: Full `latprobe` Python Package **Timestamp:** 2026-07-01 12:23 **Status:** Complete ## Goal Build `python/latprobe/` — a packaged Python port of the Go `latprobe` CLI that mirrors its behavior: flags, sampling, cross-URL concurrency, JSON output, and distinct exit codes. ## Package layout ``` python/ latprobe/ __init__.py — package marker __main__.py — sys.exit(cli.run(sys.argv[1:], sys.stdout, sys.stderr)) probe.py — measure(url, opts) -> Result (raw socket timing) aggregate.py — summarize(results) -> Aggregate (min/avg/max per phase) cli.py — run(args, stdout, stderr) -> int (argparse + rendering) duration.py — parse_duration("10s") -> float seconds tests/ test_probe.py — unittest: success, DNS fail, connect refused, timeout, bad scheme test_cli.py — unittest: drives cli.run() in-process, all branches + JSON ``` ## Key design decisions - **`probe.py`**: reuses the raw-socket technique from `phases.py` with an `Options(timeout)` parameter. Partial phases preserved on failure. - **`aggregate.py`**: `summarize(List[Result]) -> Aggregate` computes `PhaseStats(min_ms, avg_ms, max_ms, present)` per phase. Uses only `succeeded` results. - **`duration.py`**: parses `"10s"`, `"500ms"`, `"2m"`, bare numbers → float seconds. - **`cli.py`**: injectable `run(args, stdout, stderr) -> int` seam for testability. - `_Parser` subclasses `argparse.ArgumentParser` with custom `print_help`, `print_usage`, `error`, `exit` to redirect all output to injected streams and raise `_ArgExit` instead of calling `sys.exit`. - `ThreadPoolExecutor` for cross-URL concurrency; `executor.map` preserves input order. - Four text-rendering branches: single-success, multi-sample aggregate, all-failed, mixed. - Failure summary groups errors by `(phase, message)` preserving insertion order; `N ×` prefix when grouped. ## CLI flags | Flag | Default | Description | |------|---------|-------------| | `urls` (positional, `+`) | required | One or more URLs | | `-n`/`--count` | 1 | Requests per URL | | `-c`/`--concurrency` | 0 (auto) | Max parallel URLs; auto = `min(numURLs, 8)` | | `--timeout` | `10s` | Per-request timeout (parsed via `duration.py`) | | `--fail` | off | Exit non-zero on HTTP status >= 400 | | `--json` | off | Output as JSON array instead of text | ## Exit codes | Code | Meaning | |------|---------| | 0 | All probes succeeded (with `--fail`: no 4xx/5xx) | | 1 | Usage / argument error | | 2 | DNS failure | | 3 | TCP connect failure | | 4 | Timeout | | 5 | TLS error | | 6 | HTTP status >= 400 (`--fail` only) | Worst (highest) code across all URLs wins. ## JSON schema ```json [ { "url": "https://example.com", "status": 200, "succeeded": 3, "failed": 2, "phases": { "dns": {"min_ms": 1.23, "avg_ms": 1.45, "max_ms": 1.67}, "connect": {"min_ms": ...}, "tls": {"min_ms": ...}, "ttfb": {"min_ms": ...}, "transfer": {"min_ms": ...}, "total": {"min_ms": ...} }, "errors": [ {"phase": "connect", "count": 2, "message": "..."} ] } ] ``` - `phases` omitted when `succeeded == 0` - Per-phase key omitted when no successful sample had that phase (e.g. `tls` for `http://`) - `errors` omitted when `failed == 0` ## Text output format Single sample: ``` https://example.com (200) DNS lookup : 18.87 ms TCP connect : 9.76 ms TLS handshake : 14.71 ms Server (TTFB) : 67.07 ms Transfer : 0.14 ms ───────────────────────────── Total : 118.39 ms ``` Aggregate (-n 3): ``` https://example.com (200, 3 samples) min avg max DNS lookup : 2.36 ms 3.51 ms 5.00 ms TCP connect : 10.25 ms 17.45 ms 31.61 ms TLS handshake : 14.04 ms 41.60 ms 96.50 ms Server (TTFB) : 63.30 ms 66.72 ms 69.78 ms Transfer : 0.26 ms 0.38 ms 0.51 ms ───────────────────────────────────────────────── Total : 101.33 ms 139.19 ms 211.99 ms ``` ## Testing approach - `test_probe.py`: real `http.server.HTTPServer` in a daemon thread; closed port for connection-refused; `.invalid` TLD for DNS failure; black-hole socket (accepts TCP, never sends data) to trigger TTFB timeout. - `test_cli.py`: in-process `cli.run(args, io.StringIO(), io.StringIO())` — no subprocess, fast. Covers: usage errors, single/aggregate text, failure exit codes, worst-code accumulation, `--fail`, JSON schema, JSON ordering, JSON error grouping. ## Makefile changes - `py-test`: added `PYTHONPATH=$(PY_DIR)` so tests can `import latprobe`; removed `|| true` now that real tests exist.