Files
Jan Novak 24ea9c9e71 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>
2026-07-01 13:45:56 +02:00

442 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import concurrent.futures
import datetime
import json
import sys
from typing import IO
from .aggregate import Aggregate, PhaseStats, summarize
from .duration import parse_duration
from .probe import Options, Result, VerboseDetail, measure
# ── exit codes (mirrors Go) ───────────────────────────────────────────────────
EXIT_OK = 0
EXIT_USAGE = 1
EXIT_DNS = 2
EXIT_CONNECT = 3
EXIT_TIMEOUT = 4
EXIT_TLS = 5
EXIT_HTTP = 6
_PHASE_EXIT: dict[str, int] = {
"dns": EXIT_DNS,
"timeout": EXIT_TIMEOUT,
"tls": EXIT_TLS,
}
def _phase_code(fail_phase: str) -> int:
return _PHASE_EXIT.get(fail_phase, EXIT_CONNECT)
# ── display constants ─────────────────────────────────────────────────────────
_SINGLE_SEP = " " + "" * 29
_AGG_SEP = " " + "" * 49
_PHASE_LABELS = [
("dns", "DNS lookup "),
("connect", "TCP connect "),
("tls", "TLS handshake "),
("ttfb", "Server (TTFB) "),
("transfer", "Transfer "),
]
# Response headers shown in text verbose block, in priority order.
# In JSON verbose mode all parsed headers are included.
_VERBOSE_HEADERS = [
"Location",
"Server",
"Content-Type",
"X-Cache",
"CF-Cache-Status",
"Cache-Control",
"Via",
"X-Powered-By",
]
# ── argparse with injectable streams ─────────────────────────────────────────
class _ArgExit(Exception):
def __init__(self, code: int) -> None:
self.code = code
class _Parser(argparse.ArgumentParser):
"""ArgumentParser that writes to injected streams and raises instead of exiting."""
def __init__(self, *args, out: IO, err: IO, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._out = out
self._err = err
def _print_message(self, message: str, file=None) -> None:
if message:
(file if file is not None else self._out).write(message)
def print_help(self, file=None) -> None:
self._print_message(self.format_help(), self._out)
def print_usage(self, file=None) -> None:
self._print_message(self.format_usage(), self._err)
def error(self, message: str) -> None:
self.print_usage()
self._err.write(f"{self.prog}: error: {message}\n")
raise _ArgExit(EXIT_USAGE)
def exit(self, status: int = 0, message: str | None = None) -> None:
if message:
self._err.write(message)
raise _ArgExit(int(status) if status else EXIT_OK)
# ── sampling ──────────────────────────────────────────────────────────────────
def _run_samples(
url: str, count: int, opts: Options
) -> tuple[list[Result], list[Result]]:
succeeded, failed = [], []
for _ in range(count):
r = measure(url, opts)
(failed if r.err else succeeded).append(r)
return succeeded, failed
# ── verbose rendering ─────────────────────────────────────────────────────────
def _cert_expired(expiry: str) -> bool:
try:
return datetime.date.fromisoformat(expiry) < datetime.date.today()
except (ValueError, TypeError):
return False
def _print_verbose_block(detail: VerboseDetail, out: IO) -> None:
"""Write the verbose detail block (IP, TLS, cert, headers) after timing rows."""
def _row(label: str, value: str) -> None:
out.write(f" {label:<14} : {value}\n")
if detail.resolved_ip:
_row("IP", detail.resolved_ip)
if detail.tls_version:
parts = [detail.tls_version]
if detail.tls_cipher:
parts.append(detail.tls_cipher)
if detail.tls_bits:
parts.append(f"{detail.tls_bits} bit")
_row("TLS", " ".join(parts))
if detail.cert:
c = detail.cert
label = "Cert (unvrf.)" if not c.verified else "Cert"
cert_parts = []
if c.cn:
cert_parts.append(f"CN={c.cn}")
if c.expiry:
tag = "EXPIRED" if _cert_expired(c.expiry) else "valid until"
cert_parts.append(f"{tag} {c.expiry}")
if c.issuer_cn:
cert_parts.append(c.issuer_cn)
_row(label, " ".join(cert_parts))
for name in _VERBOSE_HEADERS:
value = detail.headers.get(name)
if value:
_row(name, value)
# ── text rendering ────────────────────────────────────────────────────────────
def _status_str(status_code: int, fail_flag: bool) -> str:
s = str(status_code)
if fail_flag and status_code >= 400:
s += ""
return s
def _print_single(r: Result, fail_flag: bool, out: IO) -> None:
status = _status_str(r.status_code, fail_flag)
out.write(f"{r.url} ({status})\n")
for attr, label in _PHASE_LABELS:
ph = getattr(r, attr)
if ph.present:
out.write(f" {label} : {ph.ms:8.2f} ms\n")
out.write(_SINGLE_SEP + "\n")
if r.total.present:
out.write(f" {'Total '} : {r.total.ms:8.2f} ms\n")
if r.err is not None:
out.write(f"{r.fail_phase}: {r.err}\n")
if r.detail is not None:
_print_verbose_block(r.detail, out)
def _print_aggregate(
agg: Aggregate,
failed: list[Result],
fail_flag: bool,
out: IO,
detail: VerboseDetail | None = None,
) -> None:
status = _status_str(agg.status_code, fail_flag)
header = f"{agg.url} ({status}, {agg.count} samples"
if failed:
header += f", {len(failed)} failed"
out.write(header + ")\n")
if agg.total.present:
out.write(f" {'':14} {'min':>9} {'avg':>9} {'max':>9}\n")
for attr, label in _PHASE_LABELS:
ps: PhaseStats = getattr(agg, attr)
if ps.present:
out.write(
f" {label} : {ps.min_ms:6.2f} ms"
f" {ps.avg_ms:6.2f} ms"
f" {ps.max_ms:6.2f} ms\n"
)
out.write(_AGG_SEP + "\n")
out.write(
f" {'Total '} : {agg.total.min_ms:6.2f} ms"
f" {agg.total.avg_ms:6.2f} ms"
f" {agg.total.max_ms:6.2f} ms\n"
)
_print_failure_summary(failed, out)
if detail is not None:
_print_verbose_block(detail, out)
def _print_all_failed(
url: str, failed: list[Result], total_count: int, out: IO
) -> None:
header = f"{url} (FAILED"
if total_count > 1:
header += f", 0/{total_count} succeeded"
out.write(header + ")\n")
last = failed[-1]
any_phase = False
for attr, label in _PHASE_LABELS:
ph = getattr(last, attr)
if ph.present:
out.write(f" {label} : {ph.ms:8.2f} ms\n")
any_phase = True
if last.total.present:
if any_phase:
out.write(_SINGLE_SEP + "\n")
out.write(f" {'Total '} : {last.total.ms:8.2f} ms\n")
_print_failure_summary(failed, out)
if last.detail is not None:
_print_verbose_block(last.detail, out)
def _print_failure_summary(failed: list[Result], out: IO) -> None:
if not failed:
return
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
for phase, msg in order:
n = counts[(phase, msg)]
if n == 1:
out.write(f"{phase}: {msg}\n")
else:
out.write(f"{n} × {phase}: {msg}\n")
def _print_url(
url: str,
succeeded: list[Result],
failed: list[Result],
total_count: int,
fail_flag: bool,
out: IO,
) -> None:
n_ok = len(succeeded)
n_fail = len(failed)
last_detail = succeeded[-1].detail if succeeded else None
if n_fail == 0 and total_count == 1:
_print_single(succeeded[0], fail_flag, out)
elif n_fail == 0:
_print_aggregate(summarize(succeeded), [], fail_flag, out, last_detail)
elif n_ok == 0:
_print_all_failed(url, failed, total_count, out)
else:
_print_aggregate(summarize(succeeded), failed, fail_flag, out, last_detail)
# ── JSON rendering ────────────────────────────────────────────────────────────
def _build_json_entry(
url: str,
succeeded: list[Result],
failed: list[Result],
detail: VerboseDetail | None = None,
) -> dict:
entry: dict = {
"url": url,
"status": 0,
"succeeded": len(succeeded),
"failed": len(failed),
}
if succeeded:
agg = summarize(succeeded)
entry["status"] = agg.status_code
phases: dict = {}
for attr in ("dns", "connect", "tls", "ttfb", "transfer", "total"):
ps: PhaseStats = getattr(agg, attr)
if ps.present:
phases[attr] = {
"min_ms": ps.min_ms,
"avg_ms": ps.avg_ms,
"max_ms": ps.max_ms,
}
if phases:
entry["phases"] = phases
if failed:
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
entry["errors"] = [
{"phase": ph, "count": counts[(ph, msg)], "message": msg}
for ph, msg in order
]
if detail is not None:
v: dict = {}
if detail.resolved_ip:
v["ip"] = detail.resolved_ip
if detail.tls_version:
v["tls_version"] = detail.tls_version
v["tls_cipher"] = detail.tls_cipher
v["tls_bits"] = detail.tls_bits
if detail.cert:
c = detail.cert
v["cert"] = {
"cn": c.cn,
"sans": c.sans,
"expiry": c.expiry,
"issuer_cn": c.issuer_cn,
"verified": c.verified,
}
if detail.headers:
v["headers"] = dict(detail.headers)
if v:
entry["verbose"] = v
return entry
# ── entry point ───────────────────────────────────────────────────────────────
def run(args: list[str], stdout: IO, stderr: IO) -> int:
parser = _Parser(
prog="latprobe",
description="measure per-phase HTTP request latency",
out=stdout,
err=stderr,
)
parser.add_argument("urls", nargs="+", metavar="url")
parser.add_argument(
"-n", "--count", type=int, default=1, metavar="N",
help="number of requests per URL (default: 1)",
)
parser.add_argument(
"-c", "--concurrency", type=int, default=0, metavar="N",
help="max parallel URLs, 0=auto (default: 0)",
)
parser.add_argument(
"--timeout", default="10s", metavar="DURATION",
help="per-request timeout, e.g. 10s, 500ms (default: 10s)",
)
parser.add_argument(
"--fail", action="store_true",
help="exit non-zero on HTTP status >= 400",
)
parser.add_argument(
"--json", action="store_true", dest="json_out",
help="output results as JSON instead of text",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="show resolved IP, TLS version/cipher, certificate, and response headers",
)
try:
ns = parser.parse_args(args)
except _ArgExit as exc:
return exc.code
try:
timeout_secs = parse_duration(ns.timeout)
except ValueError:
stderr.write(f"latprobe: error: invalid timeout: {ns.timeout!r}\n")
return EXIT_USAGE
opts = Options(timeout=timeout_secs, verbose=ns.verbose)
urls = ns.urls
count = ns.count
workers = ns.concurrency
if workers <= 0:
workers = min(len(urls), 8)
workers = max(1, min(workers, len(urls)))
def _probe(url: str) -> tuple[list[Result], list[Result]]:
return _run_samples(url, count, opts)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
all_results = list(ex.map(_probe, urls))
worst = EXIT_OK
json_items = []
for i, (url, (succeeded, failed)) in enumerate(zip(urls, all_results)):
for r in failed:
c = _phase_code(r.fail_phase)
if c > worst:
worst = c
if ns.fail:
for r in succeeded:
if r.status_code >= 400:
worst = max(worst, EXIT_HTTP)
last_detail = succeeded[-1].detail if succeeded else (
failed[-1].detail if failed else None
)
if ns.json_out:
json_items.append(_build_json_entry(url, succeeded, failed, last_detail))
continue
if i > 0:
stdout.write("\n")
_print_url(url, succeeded, failed, count, ns.fail, stdout)
if ns.json_out:
stdout.write(json.dumps(json_items, indent=2) + "\n")
return worst