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>
442 lines
14 KiB
Python
442 lines
14 KiB
Python
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
|