feat(py): step 3 — full latprobe package with --verbose flag
Adds the complete Python port of the Go latprobe tool plus diagnostic verbose output that the Go version does not yet have. Package (python/latprobe/): - probe.py: raw-socket HTTP timing with CertInfo/VerboseDetail dataclasses; TTFB loop accumulates to \r\n\r\n so headers are parseable without changing t_first_byte semantics; captures resolved IP, TLS version/cipher/bits, verified cert (via getpeercert()), and all response headers when verbose=True - aggregate.py: summarize() → per-phase min/avg/max PhaseStats - cli.py: injectable run(args,stdout,stderr)->int; argparse with injected streams; ThreadPoolExecutor concurrency across URLs; four text-rendering branches; JSON output; worst-exit-code accumulation; -v/--verbose flag appends IP/TLS/cert/header block after every timing table; JSON extended with "verbose" object (omitted when flag absent) - duration.py: parse Go-style duration strings (10s, 500ms, 2m, bare seconds) - Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400 Tests: - test_probe.py: 18 hermetic tests (success, 404, DNS/connect/timeout/scheme failures, partial-phase invariants, verbose detail fields) - test_cli.py: 38 hermetic tests (usage errors, single/aggregate/failure text, worst-code, --fail, JSON schema/ordering/grouping, verbose text and JSON) - test_integration.py: 45 tests against live internet services (badssl.com for TLS errors, real cert/IP/header validation in verbose mode) Makefile: PYTHONPATH fix, fnmatch pattern test_[!i]*.py to exclude integration tests from py-test, new py-test-integration target Docs: docs/usage/py-latprobe.md, python/configs/usage-latprobe.md (runnable reference with captured output), plans for both the package and verbose feature Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
139
docs/plans/2026-07-01-12-23-py-latprobe.md
Normal file
139
docs/plans/2026-07-01-12-23-py-latprobe.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user