feat(hxprobe): httpx-based HTTP probe — full standalone package

Third Python implementation in the latency-tool progression.
Mirrors latprobe's feature set but replaces raw sockets with httpx,
gaining HTTP/2 support and redirect following.

Package layout (hxprobe/hxprobe/):
- probe.py: asyncio + httpx.AsyncClient with a _TimingStream transport
  wrapper that captures DNS/connect/TLS/TTFB/transfer phase timings via
  httpx event hooks (get_connection_stats, request_started, etc.).
  VerboseDetail captures resolved IP, TLS version/cipher/bits, cert CN/
  expiry/issuer (from httpx's SSLObject), and response headers.
  Options: timeout, verbose, follow_redirects, http2
- aggregate.py: summarize() → per-phase min/avg/max (same schema as latprobe)
- cli.py: run(args,stdout,stderr)->int; argparse with redirect_stdout/
  redirect_stderr + SystemExit catch; -n/--count, -c/--concurrency,
  --timeout, --fail, --json, -v/--verbose, --no-follow-redirects,
  --no-http2, -f/--file (URL list from file, mutually exclusive with args);
  multi-URL summary footer (tally + exit label) when len(urls) > 1;
  worst-exit-code logic mirrors Go/latprobe; JSON output is bare array
- duration.py: same parse_duration() as latprobe
- Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400

Tests (hxprobe/tests/):
- test_probe.py: 18 hermetic tests using anyio + in-process ASGI servers
- test_cli.py: 36 hermetic tests (success, failures, JSON, verbose, -f flag,
  run-summary footer, worst-code accumulation)
- test_integration.py: pytest-marked @integration (excluded from hx-test)

Toolchain: uv + ruff + pytest; pyproject.toml with [dependency-groups];
hxprobe/Makefile standalone (help, deps, run, lint, fmt, test,
test-integration, check, clean); .python-version pins 3.14
Configs: 8 fixture files mirroring python/configs/ (all-ok through mixed)
USAGE.md: 16 runnable examples with real captured output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 13:22:31 +02:00
parent 24ea9c9e71
commit 404b372070
24 changed files with 2924 additions and 0 deletions

1
hxprobe/.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

55
hxprobe/Makefile Normal file
View File

@@ -0,0 +1,55 @@
# hxprobe — standalone Makefile. Requires uv (https://docs.astral.sh/uv/).
#
# This project is fully self-contained (own pyproject.toml, own uv.lock) and
# this Makefile works the same whether run from inside a parent monorepo or
# after `cp -r`'ing hxprobe/ into its own repo. It does not depend on, and is
# not depended on by, the parent repo's root Makefile — the two are kept
# independent on purpose so a change to one can't break the other.
PYTHON ?= python3.14
ARGS ?=
.DEFAULT_GOAL := help
# ── help ──────────────────────────────────────────────────────────────────────
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*##"}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' \
| sort
# ── targets ───────────────────────────────────────────────────────────────────
.PHONY: deps
deps: ## sync the venv from uv.lock; idempotent
uv sync
.PHONY: run
run: deps ## run hxprobe (pass flags via ARGS="…")
uv run $(PYTHON) -m hxprobe $(ARGS)
.PHONY: lint
lint: deps ## lint with ruff
uv run ruff check .
.PHONY: fmt
fmt: deps ## format with ruff
uv run ruff format .
.PHONY: test
test: deps ## run hermetic unit tests
uv run pytest tests -m "not integration" -v $(ARGS)
.PHONY: test-integration
test-integration: deps ## run integration tests against live internet services (HTTP/2, redirects)
uv run pytest tests -m integration -v $(ARGS)
.PHONY: check
check: lint test ## run the full gate: lint + hermetic tests
.PHONY: clean
clean: ## remove bytecode, caches, and egg-info
@find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null; \
find . -name '*.pyc' -delete 2>/dev/null; \
rm -rf *.egg-info .pytest_cache .ruff_cache; true

38
hxprobe/README.md Normal file
View File

@@ -0,0 +1,38 @@
# hxprobe
Per-phase HTTP latency probe built on [httpx](https://www.python-httpx.org/),
matching Go's `http.DefaultClient`: HTTP/2 negotiated via ALPN, redirects
followed by default, connection pooling, and default TLS verification —
while reporting a six-phase timing breakdown (DNS, TCP connect, TLS, TTFB,
Transfer, Total).
This is a fully self-contained project: no imports outside this directory,
own `pyproject.toml`, own lockfile (`uv.lock`). It can be copied out of its
parent repo and used standalone.
See [docs/usage/hxprobe.md](../docs/usage/hxprobe.md) in the parent repo for
full flag reference, example output, and design notes (redirect semantics,
a TTFB-accuracy finding worth knowing about).
## Quick start
Requires [uv](https://docs.astral.sh/uv/).
```sh
uv sync # create .venv, install deps + lockfile
uv run python -m hxprobe https://example.com
uv run python -m hxprobe -v http://github.com # verbose: shows HTTP/2, redirects, TLS, cert
```
## Development
```sh
uv run pytest tests -m "not integration" -v # hermetic unit tests
uv run pytest tests -m integration -v # live tests (hits the real internet)
uv run ruff check . # lint
uv run ruff format . # format
```
From the parent repo's root, the equivalent Makefile targets are
`make hx-run`, `make hx-test`, `make hx-test-integration`, `make hx-lint`,
`make hx-fmt`, and `make hx-check` (lint + hermetic tests).

703
hxprobe/USAGE.md Normal file
View File

@@ -0,0 +1,703 @@
# `hxprobe` — Runnable Usage Reference
All commands below assume you're inside this directory (`cd hxprobe`) with
dependencies installed (`make deps` or `uv sync`). Timings will differ on
your machine and network; the output structure is stable. Every example
below was run against the live internet.
---
## Basic — single URL
```sh
make run ARGS="https://example.com"
# or directly:
uv run python -m hxprobe https://example.com
```
```
https://example.com (200)
DNS lookup : 17.98 ms
TCP connect : 8.18 ms
TLS handshake : 15.41 ms
Server (TTFB) : 3.38 ms
Transfer : 7.99 ms
─────────────────────────────
Total : 61.93 ms
```
---
## Verbose mode — IP, protocol, TLS, certificate
```sh
uv run python -m hxprobe --verbose https://example.com
```
```
https://example.com (200)
DNS lookup : 2.33 ms
TCP connect : 14.73 ms
TLS handshake : 15.22 ms
Server (TTFB) : 5.63 ms
Transfer : 0.57 ms
─────────────────────────────
Total : 49.16 ms
IP : 104.20.23.154
Protocol : HTTP/2
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
`Protocol` is the one row `latprobe` (the raw-socket sibling implementation)
never prints — it's the ALPN-negotiated HTTP version, only meaningful for a
client that can actually speak more than one.
---
## Verbose — plain HTTP (no TLS block)
```sh
uv run python -m hxprobe --verbose http://example.com
```
```
http://example.com (200)
DNS lookup : 2.15 ms
TCP connect : 8.52 ms
Server (TTFB) : 16.33 ms
Transfer : 0.42 ms
─────────────────────────────
Total : 27.80 ms
IP : 104.20.23.154
Protocol : HTTP/1.1
```
No `TLS` or `Cert` rows for `http://` URLs. `Protocol` still shows —
HTTP/2 is not attempted over cleartext (see Limitations in
[`docs/usage/hxprobe.md`](../docs/usage/hxprobe.md)), so this is always
`HTTP/1.1`.
---
## Verbose — redirect followed by default
```sh
uv run python -m hxprobe --verbose http://github.com
```
```
http://github.com (200)
DNS lookup : 14.34 ms
TCP connect : 20.88 ms
TLS handshake : 25.91 ms
Server (TTFB) : 3.44 ms
Transfer : 63.33 ms
─────────────────────────────
Total : 197.21 ms
IP : 140.82.121.3
Protocol : HTTP/2 (1 redirect)
TLS : TLSv1.3 TLS_AES_128_GCM_SHA256 128 bit
Cert : CN=github.com valid until 2026-08-02 Sectigo Limited
```
`http://github.com` 301-redirects to `https://github.com`; hxprobe follows
it by default (matching Go's `http.DefaultClient`) and reports the final
response. `latprobe` has no equivalent — it would print the bare `301` and
stop. `DNS`/`TCP connect`/`TLS` are timed from the *first* connection only;
`Server (TTFB)`/`Transfer` reflect the final hop (see "Divergence from
`latprobe`" in [`docs/usage/hxprobe.md`](../docs/usage/hxprobe.md) for why).
---
## `--no-follow-redirects` — report the raw redirect instead
```sh
uv run python -m hxprobe --no-follow-redirects http://github.com
```
```
http://github.com (301)
DNS lookup : 2.85 ms
TCP connect : 24.16 ms
Server (TTFB) : 24.50 ms
Transfer : 0.59 ms
─────────────────────────────
Total : 52.58 ms
```
Same shape `latprobe` would show for any redirect — hxprobe just makes it
opt-in rather than the default.
---
## `--no-http2` — force HTTP/1.1
```sh
uv run python -m hxprobe --verbose --no-http2 https://example.com
```
```
https://example.com (200)
DNS lookup : 2.31 ms
TCP connect : 8.75 ms
TLS handshake : 13.61 ms
Server (TTFB) : 13.51 ms
Transfer : 0.44 ms
─────────────────────────────
Total : 39.07 ms
IP : 104.20.23.154
Protocol : HTTP/1.1
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
Useful for isolating whether a latency difference is due to protocol version
rather than network conditions.
---
## Verbose — TLS failure (certificate expired)
```sh
uv run python -m hxprobe --verbose https://expired.badssl.com/
echo "exit: $?"
```
```
https://expired.badssl.com/ (FAILED)
DNS lookup : 33.34 ms
TCP connect : 1129.53 ms
TLS handshake : 318.54 ms
─────────────────────────────
Total : 1484.12 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1081)
IP : 104.154.89.105
exit: 5
```
The IP is shown even on TLS failure (DNS and TCP both succeeded). badssl.com
is a shared, often-loaded demo host — `TCP connect` here is unusually slow;
that's the host, not hxprobe.
---
## Verbose — DNS failure (no IP to show)
```sh
uv run python -m hxprobe --verbose http://no.such.host.invalid
echo "exit: $?"
```
```
http://no.such.host.invalid (FAILED)
Total : 13.22 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
exit: 2
```
The verbose block is empty (IP never resolved), so it's suppressed entirely
— same behavior as `latprobe`.
---
## Sampling (`-n`) — min / avg / max table
```sh
uv run python -m hxprobe -n 3 https://example.com
```
```
https://example.com (200, 3 samples)
min avg max
DNS lookup : 1.23 ms 2.29 ms 3.02 ms
TCP connect : 8.05 ms 8.73 ms 9.81 ms
TLS handshake : 12.99 ms 15.65 ms 17.48 ms
Server (TTFB) : 0.01 ms 7.23 ms 11.95 ms
Transfer : 0.63 ms 2.18 ms 5.15 ms
─────────────────────────────────────────────────
Total : 38.56 ms 49.16 ms 54.70 ms
```
With `--verbose`, the block is appended below the table using the last
successful sample's detail:
```sh
uv run python -m hxprobe --verbose -n 3 https://example.com
```
```
https://example.com (200, 3 samples)
min avg max
...
Total : 39.95 ms 42.55 ms 44.66 ms
IP : 104.20.23.154
Protocol : HTTP/2
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
---
## Multiple URLs (probed in parallel)
```sh
uv run python -m hxprobe https://example.com https://www.iana.org
```
```
https://example.com (200)
DNS lookup : 3.96 ms
TCP connect : 13.13 ms
TLS handshake : 15.31 ms
Server (TTFB) : 0.01 ms
Transfer : 7.20 ms
─────────────────────────────
Total : 50.16 ms
https://www.iana.org (200)
DNS lookup : 26.67 ms
TCP connect : 9.05 ms
TLS handshake : 14.73 ms
Server (TTFB) : 3.37 ms
Transfer : 0.63 ms
─────────────────────────────
Total : 70.42 ms
─────────────────────────────────────────────────
Summary: 2 URLs — 2 ok
→ exit 0 (ok)
```
Output for each URL is separated by a blank line. Exit code is the worst
across all — see "Multi-URL summary footer" below for how that scalar breaks
down when URLs have different outcomes.
---
## Multi-URL summary footer
Whenever more than one URL is probed (positional args or `-f`), a footer is
appended after the last URL block: a per-outcome tally plus the exit code it
produced. Single-URL runs never show it — text output is otherwise unchanged.
```sh
uv run python -m hxprobe https://example.com http://no.such.host.invalid https://self-signed.badssl.com
echo "exit: $?"
```
```
https://example.com (200)
DNS lookup : 4.65 ms
TCP connect : 9.56 ms
TLS handshake : 14.90 ms
Server (TTFB) : 0.01 ms
Transfer : 8.84 ms
─────────────────────────────
Total : 47.28 ms
http://no.such.host.invalid (FAILED)
Total : 4.54 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
https://self-signed.badssl.com (FAILED)
DNS lookup : 24.75 ms
TCP connect : 1126.05 ms
TLS handshake : 327.05 ms
─────────────────────────────
Total : 1478.35 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1081)
─────────────────────────────────────────────────
Summary: 3 URLs — 1 ok, 2 failed
✗ dns : 1
✗ tls : 1
→ exit 5 (tls)
exit: 5
```
Each URL is classified by its own worst sample (same `_phase_code` mapping the
overall exit code uses), then tallied. The `→ exit N (label)` line ties the
tally directly to the process exit code, since the code alone can't show
*which* URLs failed *how* — here a DNS failure (would be exit `2` alone) is
outranked by the TLS failure (`5`), and the footer is what makes that visible.
JSON output (`--json`) is unaffected — it stays a bare array; per-URL failures
are already in each entry's `errors[]`.
---
## Reading URLs from a file (`-f`)
```sh
uv run python -m hxprobe -f configs/all-ok.txt
```
```
https://example.com (200)
DNS lookup : 3.60 ms
TCP connect : 17.61 ms
TLS handshake : 16.39 ms
Server (TTFB) : 4.06 ms
Transfer : 0.60 ms
─────────────────────────────
Total : 56.24 ms
https://www.google.com (200)
DNS lookup : 4.18 ms
TCP connect : 17.23 ms
TLS handshake : 30.62 ms
Server (TTFB) : 2.68 ms
Transfer : 69.83 ms
─────────────────────────────
Total : 142.95 ms
https://www.iana.org (200)
DNS lookup : 4.25 ms
TCP connect : 17.18 ms
TLS handshake : 18.87 ms
Server (TTFB) : 8.13 ms
Transfer : 0.50 ms
─────────────────────────────
Total : 60.76 ms
─────────────────────────────────────────────────
Summary: 3 URLs — 3 ok
→ exit 0 (ok)
```
`-f`/`--file` reads a plain-text URL list — one per line, blank lines and
`#`-prefixed comment lines skipped — the same format `simple.py`/`phases.py`
use elsewhere in this repo. It's **mutually exclusive** with positional URL
arguments: pass one or the other, not both.
```sh
uv run python -m hxprobe -f configs/all-ok.txt https://extra.example.com
```
```
usage: hxprobe [-h] [-f PATH] [-n N] [-c N] [--timeout DURATION] [--fail]
[--json] [-v] [--no-http2] [--no-follow-redirects]
[url ...]
hxprobe: error: cannot combine positional url arguments with -f/--file
```
`configs/` ships one fixture per failure class, each self-documenting its
expected exit code in a header comment (verified by actually running it,
not just asserted):
| File | Demonstrates | Exit code |
|------|---------------|-----------|
| `configs/all-ok.txt` | Everything succeeds | 0 |
| `configs/dns-failure.txt` | Unresolvable hostnames | 2 |
| `configs/connection-refused.txt` | Loopback ports with no listener | 3 |
| `configs/timeout.txt` | Non-routable IPs (RFC 5737 TEST-NET-1) | 4 |
| `configs/tls-errors.txt` | badssl.com cert failures | 5 |
| `configs/http-errors.txt` | 404s, run with `--fail` | 6 |
| `configs/mixed.txt` | One of each class, run with `--fail` | 6 |
| `configs/large-mixed.txt` | 30 URLs, ≥10 failing across all 5 classes — good demo of the multi-URL summary footer | 6 |
```sh
uv run python -m hxprobe -f configs/dns-failure.txt
echo "exit: $?"
```
```
https://this-host-does-not-exist.invalid (FAILED)
Total : 16.91 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
http://no.such.host.invalid (FAILED)
Total : 4.56 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
─────────────────────────────────────────────────
Summary: 2 URLs — 0 ok, 2 failed
✗ dns : 2
→ exit 2 (dns)
exit: 2
```
`configs/large-mixed.txt` scales this up to 30 URLs so the summary footer has
something substantial to tally — 20 real sites expected to succeed plus 10
deliberately broken across all five failure classes at once:
```sh
uv run python -m hxprobe --fail --timeout 2s -f configs/large-mixed.txt
echo "exit: $?"
```
```
https://example.com (200)
DNS lookup : 3.96 ms
TCP connect : 10.24 ms
TLS handshake : 14.76 ms
Server (TTFB) : 4.14 ms
Transfer : 3.71 ms
─────────────────────────────
Total : 49.54 ms
... 18 more successful URLs ...
https://stackoverflow.com (200)
DNS lookup : 12.99 ms
TCP connect : 9.00 ms
TLS handshake : 16.58 ms
Server (TTFB) : 197.72 ms
Transfer : 289.92 ms
─────────────────────────────
Total : 684.24 ms
https://this-host-does-not-exist.invalid (FAILED)
Total : 1.25 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
http://no.such.host.invalid (FAILED)
Total : 0.92 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
http://127.0.0.1:9999 (FAILED)
DNS lookup : 0.01 ms
TCP connect : 0.09 ms
─────────────────────────────
Total : 0.26 ms
✗ connect: [Errno 61] Connection refused
http://127.0.0.1:19999 (FAILED)
DNS lookup : 0.01 ms
TCP connect : 0.09 ms
─────────────────────────────
Total : 0.23 ms
✗ connect: [Errno 61] Connection refused
http://10.255.255.1/ (FAILED)
DNS lookup : 0.03 ms
TCP connect : 16.50 ms
─────────────────────────────
Total : 16.64 ms
✗ connect: [Errno 61] Connection refused
http://192.0.2.1/ (FAILED)
DNS lookup : 0.01 ms
TCP connect : 2001.27 ms
─────────────────────────────
Total : 2001.56 ms
✗ timeout: timed out
https://expired.badssl.com/ (FAILED)
DNS lookup : 16.37 ms
TCP connect : 1125.42 ms
TLS handshake : 329.12 ms
─────────────────────────────
Total : 1471.48 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1081)
https://self-signed.badssl.com/ (FAILED)
DNS lookup : 13.27 ms
TCP connect : 1127.27 ms
TLS handshake : 325.84 ms
─────────────────────────────
Total : 1466.90 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1081)
https://www.google.com/this-page-does-not-exist-at-all-1234567890 (404 ✗)
DNS lookup : 0.96 ms
TCP connect : 7.61 ms
TLS handshake : 25.25 ms
Server (TTFB) : 0.01 ms
Transfer : 99.63 ms
─────────────────────────────
Total : 142.38 ms
https://github.com/this-repo-does-not-exist-abcxyz123/no-way (404 ✗)
DNS lookup : 1.23 ms
TCP connect : 19.57 ms
TLS handshake : 22.02 ms
Server (TTFB) : 209.47 ms
Transfer : 67.20 ms
─────────────────────────────
Total : 339.82 ms
─────────────────────────────────────────────────
Summary: 30 URLs — 20 ok, 10 failed
✗ dns : 2
✗ connect : 3
✗ timeout : 1
✗ tls : 2
✗ http : 2
→ exit 6 (http)
exit: 6
```
The individual URL blocks above are unchanged in shape from every other
example on this page — this is purely a matter of scale. The summary footer
is where scale actually pays off: instead of scanning 30 blocks to count
outcomes, `Summary: 30 URLs — 20 ok, 10 failed` plus the per-class breakdown
answers "what happened" at a glance, and `→ exit 6 (http)` explains *why*
that particular exit code came back (the two 404s under `--fail`, at
`EXIT_HTTP = 6`, outrank every other class present).
---
## `--fail` flag — exit non-zero on HTTP 4xx/5xx
```sh
uv run python -m hxprobe --fail https://www.google.com/this-page-does-not-exist-at-all-1234567890
echo "exit: $?"
```
```
https://www.google.com/this-page-does-not-exist-at-all-1234567890 (404 ✗)
DNS lookup : 2.96 ms
TCP connect : 11.57 ms
TLS handshake : 24.95 ms
Server (TTFB) : 0.01 ms
Transfer : 104.22 ms
─────────────────────────────
Total : 152.77 ms
exit: 6
```
Without `--fail`, 4xx/5xx responses are shown normally and the exit code is `0`.
---
## JSON output
```sh
uv run python -m hxprobe --json https://example.com | python3.14 -m json.tool
```
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 1,
"failed": 0,
"phases": {
"dns": {"min_ms": 2.80, "avg_ms": 2.80, "max_ms": 2.80},
"connect": {"min_ms": 11.68, "avg_ms": 11.68, "max_ms": 11.68},
"tls": {"min_ms": 14.52, "avg_ms": 14.52, "max_ms": 14.52},
"ttfb": {"min_ms": 6.49, "avg_ms": 6.49, "max_ms": 6.49},
"transfer": {"min_ms": 0.81, "avg_ms": 0.81, "max_ms": 0.81},
"total": {"min_ms": 45.59, "avg_ms": 45.59, "max_ms": 45.59}
}
}
]
```
Same shape as `latprobe`'s JSON — `phases` is omitted when all samples
failed, `tls` is omitted for `http://` URLs.
---
## JSON + verbose
```sh
uv run python -m hxprobe --verbose --json https://example.com
```
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 1,
"failed": 0,
"phases": { "...": "..." },
"verbose": {
"ip": "104.20.23.154",
"http_version": "HTTP/2",
"redirect_count": 0,
"tls_version": "TLSv1.3",
"tls_cipher": "TLS_AES_256_GCM_SHA384",
"tls_bits": 256,
"cert": {
"cn": "example.com",
"sans": ["example.com", "*.example.com"],
"expiry": "2026-08-29",
"issuer_cn": "SSL Corporation",
"verified": true
},
"headers": {
"content-type": "text/html",
"server": "cloudflare",
"cf-cache-status": "HIT"
}
}
}
]
```
`"http_version"`/`"redirect_count"` are the two keys `latprobe`'s JSON never
has (its `VerboseDetail.http_version` stays `""`). `"headers"` contains
**all** parsed response headers (the text view shows only a priority list).
---
## Timeout
```sh
uv run python -m hxprobe --timeout 500ms http://192.0.2.1/
echo "exit: $?"
```
```
http://192.0.2.1/ (FAILED)
DNS lookup : 3.11 ms
TCP connect : 501.51 ms
─────────────────────────────
Total : 505.20 ms
✗ timeout: timed out
exit: 4
```
`192.0.2.1` is in RFC 5737's TEST-NET-1 range — reserved for documentation,
guaranteed unreachable, and the kernel gets no reply so the connect phase
runs the full `--timeout` before giving up. `--timeout` accepts `ms`, `s`,
`m` suffixes or a bare number of seconds.
---
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded (or HTTP 4xx without `--fail`) |
| 1 | Usage / argument error |
| 2 | DNS failure |
| 3 | TCP connect failure |
| 4 | Timeout |
| 5 | TLS error |
| 6 | HTTP status ≥ 400 with `--fail` |
The **highest** exit code across all URLs is returned as the process exit —
identical scheme to `latprobe` and the Go implementation. This scalar is kept
unchanged for compatibility; for multi-URL runs, the "Multi-URL summary
footer" section above shows the full per-URL breakdown behind it.
---
## Makefile shortcuts
From inside this directory:
```sh
make run ARGS="--verbose https://example.com" # run hxprobe
make test # hermetic tests only
make test-integration # live internet tests
make lint # ruff check
make fmt # ruff format
make check # lint + hermetic tests
```
From the parent repo's root, the equivalent shortcuts are prefixed `hx-`:
```sh
make hx-run ARGS="--verbose https://example.com"
make hx-test
make hx-test-integration
make hx-check
```
Both Makefiles are independent — neither calls into the other — so either
works whether you're inside a checkout of the full monorepo or a standalone
copy of just `hxprobe/`.

View File

@@ -0,0 +1,7 @@
# All sites are expected to respond successfully.
# Run: uv run python -m hxprobe -f configs/all-ok.txt
# Expected exit code: 0
https://example.com
https://www.google.com
https://www.iana.org

View File

@@ -0,0 +1,7 @@
# Connection-refused errors — loopback addresses with no server on those ports.
# The OS rejects the TCP SYN immediately, so these fail in milliseconds.
# Run: uv run python -m hxprobe -f configs/connection-refused.txt
# Expected exit code: 3
http://127.0.0.1:9999
http://127.0.0.1:19999

View File

@@ -0,0 +1,9 @@
# DNS resolution failures — hostnames that cannot be resolved.
# .invalid is an IANA-reserved TLD guaranteed never to resolve (RFC 2606).
# Run: uv run python -m hxprobe -f configs/dns-failure.txt
# Expected exit code: 2
https://this-host-does-not-exist.invalid
http://no.such.host.invalid
# Also exercises the blank-line and comment-line parser paths.

View File

@@ -0,0 +1,22 @@
# HTTP error status codes — without --fail, hxprobe treats 4xx/5xx as a
# normal (successful) probe outcome, matching Go's http.DefaultClient
# semantics. --fail opts back into "HTTP error = failure", which is what
# this fixture demonstrates. The URLs below are real paths that reliably
# return 404 on stable public servers.
#
# Note: a genuine 500 from a well-known server is hard to provoke on demand.
# These 404s are sufficient to demonstrate --fail's effect on the exit code.
#
# Requires internet access.
#
# Run: uv run python -m hxprobe --fail -f configs/http-errors.txt
# Expected exit code: 6 (0 without --fail — the requests still succeed)
# 404 from Google
https://www.google.com/this-page-does-not-exist-at-all-1234567890
# 404 from GitHub
https://github.com/this-repo-does-not-exist-abcxyz123/no-way
# 404 from IANA
https://www.iana.org/this-page-does-not-exist-either

View File

@@ -0,0 +1,67 @@
# Large mixed run — 30 URLs, 20 expected to succeed and 10 to fail, covering
# all five failure classes (dns/connect/timeout/tls/http) at once. A bigger
# sibling of mixed.txt, and a good demo of the multi-URL summary footer
# (only shown when more than one URL is probed) — the footer tallies exactly
# how many of the 10 fall into each class, since the single worst exit code
# can't show that on its own.
#
# --timeout 2s shortens the two timeout entries from the 10s default; --fail
# is required for the two 404s to count as failures (without it they're
# "successful" 404 responses, per Go http.DefaultClient semantics, and only
# 8 of the 30 would fail).
#
# Requires internet access.
#
# Run: uv run python -m hxprobe --fail --timeout 2s -f configs/large-mixed.txt
# Expected exit code: 6 (http wins — the highest class present)
# Expected: at least 10 of 30 fail, spanning all 5 classes. The *exact*
# per-class mix can shift with network conditions — same caveat as
# tls-errors.txt (badssl.com may reset/timeout instead of a clean TLS error)
# and timeout.txt (a sandboxed network may refuse instantly instead of
# timing out); a couple of the "OK" sites were swapped out during testing
# for being unreliable in some sandboxes (Wikipedia's bot detection,
# httpbin.org's frequent overload). Verified by an actual run:
# 30 URLs — 20 ok, 10 failed (dns: 2, connect: 3, timeout: 1, tls: 2,
# http: 2) → exit 6.
# ── OK (20) ──────────────────────────────────────────────────────────────
https://example.com
https://example.org
https://example.net
https://www.google.com
https://www.iana.org
https://github.com
https://www.debian.org
https://www.postgresql.org
https://www.mozilla.org
https://developer.mozilla.org
https://www.python.org
https://pypi.org
https://www.cloudflare.com
https://www.apache.org
https://www.rust-lang.org
https://go.dev
https://nodejs.org
https://www.w3.org
https://www.ietf.org
https://stackoverflow.com
# ── DNS failure (2) — .invalid is IANA-reserved, RFC 2606 ──────────────────
https://this-host-does-not-exist.invalid
http://no.such.host.invalid
# ── Connection refused (2) — loopback ports with no listener ───────────────
http://127.0.0.1:9999
http://127.0.0.1:19999
# ── Timeout (2) — RFC 5737 TEST-NET-1, non-routable ─────────────────────────
http://10.255.255.1/
http://192.0.2.1/
# ── TLS certificate errors (2) — badssl.com ─────────────────────────────────
https://expired.badssl.com/
https://self-signed.badssl.com/
# ── HTTP errors (2) — real 404s, need --fail to count as failures ──────────
https://www.google.com/this-page-does-not-exist-at-all-1234567890
https://github.com/this-repo-does-not-exist-abcxyz123/no-way

23
hxprobe/configs/mixed.txt Normal file
View File

@@ -0,0 +1,23 @@
# Mixed — one entry from each error class alongside a successful site.
# Shows that OK and FAIL lines can interleave in the same run, and that the
# exit code is the *worst* code across every URL (2=dns, 3=connect, 5=tls,
# 6=http-with-fail — 6 wins here). Timeout is omitted so the run completes
# in a few seconds; --fail is needed for the 404 line to count as a failure.
#
# Run: uv run python -m hxprobe --fail -f configs/mixed.txt
# Expected exit code: 6
# Success
https://example.com
# DNS failure
https://no.such.host.invalid
# Connection refused (loopback, no server)
http://127.0.0.1:9999
# TLS certificate error
https://self-signed.badssl.com/
# HTTP error (server responded with 404; needs --fail to count as a failure)
https://github.com/this-repo-does-not-exist-abcxyz123/no-way

View File

@@ -0,0 +1,16 @@
# Timeout — non-routable IP addresses that accept no TCP traffic.
# The kernel sends a SYN but never gets a reply; hxprobe waits the full
# --timeout per host before giving up. Default --timeout is 10s (~20s total
# for two hosts); --timeout 2s below shortens the demo.
#
# 10.255.255.1 and 192.0.2.1 (RFC 5737 TEST-NET-1, documentation-only range)
# are guaranteed to be unreachable on any normal network. (In some sandboxed
# dev environments one of these may instead get an immediate "connection
# refused" from the network layer rather than a true timeout — if that
# happens here, the other target still demonstrates the timeout path.)
#
# Run: uv run python -m hxprobe --timeout 2s -f configs/timeout.txt
# Expected exit code: 4
http://10.255.255.1/
http://192.0.2.1/

View File

@@ -0,0 +1,22 @@
# TLS certificate errors — badssl.com provides endpoints with intentionally
# broken certificates. hxprobe uses the same stdlib ssl verification as
# latprobe and rejects them by default.
#
# Note: badssl.com may intermittently reset the connection instead of
# completing the TLS handshake. The error message will then read
# "[Errno 54] Connection reset by peer" rather than CERTIFICATE_VERIFY_FAILED,
# but hxprobe still correctly reports a tls/connect failure in either case.
#
# Requires internet access.
#
# Run: uv run python -m hxprobe -f configs/tls-errors.txt
# Expected exit code: 5
# Certificate has expired
https://expired.badssl.com/
# Certificate is self-signed (not trusted by the system CA store)
https://self-signed.badssl.com/
# Certificate chain is incomplete (intermediate CA missing)
https://incomplete-chain.badssl.com/

View File

@@ -0,0 +1,7 @@
"""hxprobe — httpx-based per-phase HTTP latency probe.
Uses the httpx library so the client matches Go's http.DefaultClient: HTTP/2
via ALPN, redirects followed by default, and connection pooling — while
still reporting a six-phase breakdown (DNS, TCP connect, TLS, TTFB, Transfer,
Total) by instrumenting httpx's network backend directly.
"""

View File

@@ -0,0 +1,5 @@
import sys
from .cli import run
sys.exit(run(sys.argv[1:], sys.stdout, sys.stderr))

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass, field
from .probe import Result
@dataclass
class PhaseStats:
min_ms: float = 0.0
avg_ms: float = 0.0
max_ms: float = 0.0
present: bool = False
@dataclass
class Aggregate:
url: str = ""
count: int = 0
status_code: int = 0
dns: PhaseStats = field(default_factory=PhaseStats)
connect: PhaseStats = field(default_factory=PhaseStats)
tls: PhaseStats = field(default_factory=PhaseStats)
ttfb: PhaseStats = field(default_factory=PhaseStats)
transfer: PhaseStats = field(default_factory=PhaseStats)
total: PhaseStats = field(default_factory=PhaseStats)
def summarize(results: list[Result]) -> Aggregate:
"""Compute per-phase min/avg/max over a list of succeeded Results."""
if not results:
return Aggregate()
a = Aggregate(url=results[0].url, count=len(results))
a.status_code = results[-1].status_code
def _stats(attr: str) -> PhaseStats:
vals = [getattr(r, attr).ms for r in results if getattr(r, attr).present]
if not vals:
return PhaseStats()
return PhaseStats(
min_ms=min(vals),
avg_ms=sum(vals) / len(vals),
max_ms=max(vals),
present=True,
)
a.dns = _stats("dns")
a.connect = _stats("connect")
a.tls = _stats("tls")
a.ttfb = _stats("ttfb")
a.transfer = _stats("transfer")
a.total = _stats("total")
return a

523
hxprobe/hxprobe/cli.py Normal file
View File

@@ -0,0 +1,523 @@
from __future__ import annotations
import argparse
import concurrent.futures
import contextlib
import datetime
import json
from typing import IO
from .aggregate import Aggregate, PhaseStats, summarize
from .duration import parse_duration
from .probe import Options, Result, VerboseDetail, measure
# ── exit codes ────────────────────────────────────────────────────────────────
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)
# Labels for the end-of-run summary footer — inverse of _PHASE_EXIT plus the
# two codes it doesn't cover (EXIT_OK, EXIT_HTTP via --fail).
_EXIT_LABELS: dict[int, str] = {
EXIT_OK: "ok",
EXIT_DNS: "dns",
EXIT_CONNECT: "connect",
EXIT_TIMEOUT: "timeout",
EXIT_TLS: "tls",
EXIT_HTTP: "http",
}
# ── 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",
]
# ── 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, protocol, 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.http_version:
proto = detail.http_version
if detail.redirect_count:
proto += (
f" ({detail.redirect_count} redirect{'s' if detail.redirect_count != 1 else ''})"
)
_row("Protocol", proto)
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 {ps.avg_ms:6.2f} ms {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 _summarize_failures(failed: list[Result]) -> list[tuple[str, str, int]]:
"""Dedupe failures into (phase, message, count), preserving first-seen order."""
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 [(phase, msg, counts[(phase, msg)]) for phase, msg in order]
def _print_failure_summary(failed: list[Result], out: IO) -> None:
for phase, msg, n in _summarize_failures(failed):
if n == 1:
out.write(f"{phase}: {msg}\n")
else:
out.write(f"{n} × {phase}: {msg}\n")
def _print_run_summary(urls: list[str], url_codes: list[int], worst: int, out: IO) -> None:
"""End-of-run footer tallying every URL's outcome — only called for
multi-URL runs, where the single worst-code exit can't show the mix of
failure classes behind it."""
n_ok = sum(1 for c in url_codes if c == EXIT_OK)
n_failed = len(urls) - n_ok
out.write("\n" + _AGG_SEP + "\n")
header = f" Summary: {len(urls)} URLs — {n_ok} ok"
if n_failed:
header += f", {n_failed} failed"
out.write(header + "\n")
if n_failed:
counts: dict[int, int] = {}
order: list[int] = []
for c in url_codes:
if c == EXIT_OK:
continue
if c not in counts:
order.append(c)
counts[c] = 0
counts[c] += 1
for c in order:
label = _EXIT_LABELS.get(c, str(c))
out.write(f"{label:<8}: {counts[c]}\n")
out.write(f" → exit {worst} ({_EXIT_LABELS.get(worst, str(worst))})\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:
entry["errors"] = [
{"phase": phase, "count": n, "message": msg}
for phase, msg, n in _summarize_failures(failed)
]
if detail is not None:
v: dict = {}
if detail.resolved_ip:
v["ip"] = detail.resolved_ip
if detail.http_version:
v["http_version"] = detail.http_version
v["redirect_count"] = detail.redirect_count
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
# ── URL sources ───────────────────────────────────────────────────────────────
def _load_urls(path: str) -> list[str]:
"""Read URLs from a plain-text file: one per line, '#' comments and blank
lines skipped."""
urls: list[str] = []
with open(path) as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
urls.append(line)
return urls
# ── entry point ───────────────────────────────────────────────────────────────
def run(args: list[str], stdout: IO, stderr: IO) -> int:
parser = argparse.ArgumentParser(
prog="hxprobe",
description=(
"measure per-phase HTTP request latency via httpx "
"(HTTP/2, redirects, connection pooling — matches Go's http.DefaultClient)"
),
)
parser.add_argument("urls", nargs="*", metavar="url")
parser.add_argument(
"-f",
"--file",
metavar="PATH",
help="read URLs from a file, one per line, '#' comments allowed "
"(mutually exclusive with positional url args)",
)
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, negotiated protocol/redirects, TLS version/cipher, "
"certificate, and response headers",
)
parser.add_argument(
"--no-http2",
action="store_false",
dest="http2",
default=True,
help="disable HTTP/2 negotiation, force HTTP/1.1",
)
parser.add_argument(
"--no-follow-redirects",
action="store_false",
dest="follow_redirects",
default=True,
help="do not follow HTTP redirects",
)
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
try:
timeout_secs = parse_duration(ns.timeout)
except ValueError:
stderr.write(f"hxprobe: error: invalid timeout: {ns.timeout!r}\n")
return EXIT_USAGE
if ns.file:
try:
urls = _load_urls(ns.file)
except OSError as exc:
stderr.write(f"hxprobe: error: cannot read {ns.file!r}: {exc}\n")
return EXIT_USAGE
if not urls:
stderr.write(f"hxprobe: error: no URLs found in {ns.file!r}\n")
return EXIT_USAGE
else:
urls = ns.urls
opts = Options(
timeout=timeout_secs,
verbose=ns.verbose,
follow_redirects=ns.follow_redirects,
http2=ns.http2,
)
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 = []
url_codes: list[int] = []
for i, (url, (succeeded, failed)) in enumerate(zip(urls, all_results)):
code = EXIT_OK
for r in failed:
code = max(code, _phase_code(r.fail_phase))
if ns.fail:
for r in succeeded:
if r.status_code >= 400:
code = max(code, EXIT_HTTP)
url_codes.append(code)
worst = max(worst, code)
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")
elif len(urls) > 1:
_print_run_summary(urls, url_codes, worst, stdout)
return worst

View File

@@ -0,0 +1,14 @@
def parse_duration(s: str) -> float:
"""Parse a duration string to seconds.
Suffixes: ms (milliseconds), s (seconds), m (minutes).
A bare number is treated as seconds.
"""
s = s.strip()
if s.endswith("ms"):
return float(s[:-2]) / 1000
if s.endswith("s"):
return float(s[:-1])
if s.endswith("m"):
return float(s[:-1]) * 60
return float(s)

458
hxprobe/hxprobe/probe.py Normal file
View File

@@ -0,0 +1,458 @@
from __future__ import annotations
import datetime
import socket
import ssl
import time
import urllib.parse
from dataclasses import dataclass, field
import httpcore
import httpx
# ── verbose detail dataclasses ────────────────────────────────────────────────
@dataclass
class CertInfo:
cn: str = ""
sans: list[str] = field(default_factory=list)
expiry: str = "" # ISO date "YYYY-MM-DD"
issuer_cn: str = ""
verified: bool = False # True = TLS handshake passed verification
@dataclass
class VerboseDetail:
resolved_ip: str = ""
tls_version: str = ""
tls_cipher: str = ""
tls_bits: int = 0
cert: CertInfo | None = None
headers: dict[str, str] = field(default_factory=dict) # all parsed response headers
http_version: str = ""
redirect_count: int = 0
# ── core dataclasses ──────────────────────────────────────────────────────────
@dataclass
class Options:
timeout: float = 10.0
verbose: bool = False
follow_redirects: bool = True
http2: bool = True
@dataclass
class Phase:
ms: float = 0.0
present: bool = False
@dataclass
class Result:
url: str
dns: Phase = field(default_factory=Phase)
connect: Phase = field(default_factory=Phase)
tls: Phase = field(default_factory=Phase)
ttfb: Phase = field(default_factory=Phase)
transfer: Phase = field(default_factory=Phase)
total: Phase = field(default_factory=Phase)
status_code: int = 0
fail_phase: str = ""
err: Exception | None = None
detail: VerboseDetail | None = None # populated only when opts.verbose=True
# ── cert parsing helpers ──────────────────────────────────────────────────────
def _parse_cert_date(s: str) -> str:
"""Convert SSL cert date 'Jun 14 00:00:00 2025 GMT''2025-06-14'."""
if not s:
return ""
s = " ".join(s.split()) # collapse double-spaces ("May 5 ..." → "May 5 ...")
for fmt in ("%b %d %H:%M:%S %Y %Z", "%b %d %H:%M:%S %Y"):
try:
return datetime.datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError:
pass
return ""
def _parse_cert(peer: dict, *, verified: bool) -> CertInfo:
"""Build CertInfo from the dict returned by SSLSocket.getpeercert()."""
def _attr(rdns, key: str) -> str:
for rdn in rdns:
for k, v in rdn:
if k == key:
return v
return ""
issuer = peer.get("issuer", ())
# Prefer organizationName for issuer (more human-readable than CA CN)
issuer_cn = _attr(issuer, "organizationName") or _attr(issuer, "commonName")
return CertInfo(
cn=_attr(peer.get("subject", ()), "commonName"),
sans=[v for k, v in peer.get("subjectAltName", ()) if k == "DNS"],
expiry=_parse_cert_date(peer.get("notAfter", "")),
issuer_cn=issuer_cn,
verified=verified,
)
# ── per-call trace ────────────────────────────────────────────────────────────
#
# httpx has no equivalent of Go's net/http/httptrace, so per-phase timing is
# recovered by instrumenting httpcore's NetworkBackend/NetworkStream directly
# (see _TimingBackend/_TimingStream below). One _Trace instance is created per
# measure() call and threaded through the custom backend.
#
# Semantics when redirects are followed:
# - dns / connect / tls / resolved_ip / tls_* / cert — reported from the
# FIRST connection only (first-hop-wins), mirroring the "cost of reaching
# the origin server" that Go's connectStart.IsZero() guard preserves.
# - wrote_request / first_byte (and therefore ttfb / transfer) — reflect the
# LAST hop, since each write()/read() call overwrites them. This matches
# Go's own httptrace.ClientTrace behavior: WroteRequest/GotFirstResponseByte
# fire on every redirect hop and the last one wins, because Go's hooks
# aren't guarded either.
class _Trace:
def __init__(self) -> None:
self.dns = Phase()
self.connect = Phase()
self.tls = Phase()
self.resolved_ip = ""
self.tls_version = ""
self.tls_cipher = ""
self.tls_bits = 0
self.cert_peer: dict | None = None
self.wrote_request: float | None = None
self.first_byte: float | None = None
self.fail_phase = ""
self._tls_t0 = 0.0
def mark_dns(self, start: float, end: float, *, fail: bool = False) -> None:
# On failure, no phase is "present" for a lookup that never resolved —
# only total gets set (dns.present stays False).
if fail:
self.fail_phase = "dns"
return
if not self.dns.present:
self.dns = Phase(ms=(end - start) * 1000, present=True)
def set_resolved_ip(self, ip: str) -> None:
if not self.resolved_ip:
self.resolved_ip = ip
def mark_connect(self, start: float, end: float, *, fail: str | None = None) -> None:
if not self.connect.present:
self.connect = Phase(ms=(end - start) * 1000, present=True)
if fail:
self.fail_phase = fail
def mark_tls_start(self) -> None:
self._tls_t0 = time.perf_counter()
def finish_tls(self, ssl_sock: ssl.SSLSocket | None, *, fail: str | None = None) -> None:
if not self.tls.present:
self.tls = Phase(ms=(time.perf_counter() - self._tls_t0) * 1000, present=True)
if ssl_sock is not None:
self.tls_version = ssl_sock.version() or ""
cipher = ssl_sock.cipher()
if cipher:
self.tls_cipher, _, self.tls_bits = cipher
try:
peer = ssl_sock.getpeercert()
except Exception:
peer = None
if peer:
self.cert_peer = peer
if fail:
self.fail_phase = fail
def mark_wrote_request(self) -> None:
self.wrote_request = time.perf_counter()
def mark_first_byte(self) -> None:
self.first_byte = time.perf_counter()
# ── instrumented httpcore network backend ────────────────────────────────────
class _TimingStream(httpcore.NetworkStream):
"""Wraps a raw (or TLS) socket, stamping the trace on write/read/start_tls.
httpcore's own h11/h2 connection objects call read()/write()/start_tls()
regardless of HTTP version, so this wrapper is protocol-agnostic — HTTP/2
framing, redirects, and keep-alive are still entirely owned by httpx.
"""
def __init__(self, sock: socket.socket, trace: _Trace) -> None:
self._sock = sock
self._trace = trace
self._awaiting_first_byte = False
def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
self._sock.settimeout(timeout)
try:
data = self._sock.recv(max_bytes)
except socket.timeout as exc:
raise httpcore.ReadTimeout(str(exc)) from exc
except OSError as exc:
raise httpcore.ReadError(str(exc)) from exc
if self._awaiting_first_byte:
self._awaiting_first_byte = False
self._trace.mark_first_byte()
return data
def write(self, buffer: bytes, timeout: float | None = None) -> None:
if not buffer:
return
self._sock.settimeout(timeout)
try:
self._sock.sendall(buffer)
except socket.timeout as exc:
raise httpcore.WriteTimeout(str(exc)) from exc
except OSError as exc:
raise httpcore.WriteError(str(exc)) from exc
self._trace.mark_wrote_request()
self._awaiting_first_byte = True
def close(self) -> None:
try:
self._sock.close()
except OSError:
pass
def start_tls(
self,
ssl_context: ssl.SSLContext,
server_hostname: str | None = None,
timeout: float | None = None,
) -> httpcore.NetworkStream:
# httpcore already set ALPN protocols (["http/1.1", "h2"] or just
# ["http/1.1"]) on ssl_context before calling start_tls — we just wrap.
self._trace.mark_tls_start()
self._sock.settimeout(timeout)
try:
tls_sock = ssl_context.wrap_socket(self._sock, server_hostname=server_hostname)
except socket.timeout as exc:
self._trace.finish_tls(None, fail="timeout")
raise httpcore.ConnectTimeout(str(exc)) from exc
except (ssl.SSLError, OSError) as exc:
self._trace.finish_tls(None, fail="tls")
raise httpcore.ConnectError(str(exc)) from exc
self._trace.finish_tls(tls_sock)
return _TimingStream(tls_sock, self._trace)
def get_extra_info(self, info: str):
if info == "ssl_object" and isinstance(self._sock, ssl.SSLSocket):
return self._sock
return None
class _TimingBackend(httpcore.NetworkBackend):
"""Splits DNS and TCP connect into two timed steps: httpcore's own
SyncBackend fuses them by calling socket.create_connection(), which does
getaddrinfo() and connect() internally with no way to time them apart."""
def __init__(self, trace: _Trace) -> None:
self._trace = trace
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
) -> httpcore.NetworkStream:
trace = self._trace
t0 = time.perf_counter()
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
trace.mark_dns(t0, time.perf_counter(), fail=True)
raise httpcore.ConnectError(str(exc)) from exc
trace.mark_dns(t0, time.perf_counter())
addr = infos[0][4]
family = infos[0][0]
trace.set_resolved_ip(str(addr[0]))
sock = socket.socket(family, socket.SOCK_STREAM)
if local_address is not None:
sock.bind((local_address, 0))
sock.settimeout(timeout)
t1 = time.perf_counter()
try:
sock.connect(addr)
except socket.timeout as exc:
sock.close()
trace.mark_connect(t1, time.perf_counter(), fail="timeout")
raise httpcore.ConnectTimeout(str(exc)) from exc
except OSError as exc:
sock.close()
trace.mark_connect(t1, time.perf_counter(), fail="connect")
raise httpcore.ConnectError(str(exc)) from exc
trace.mark_connect(t1, time.perf_counter())
# TCP_NODELAY (matches httpcore's own SyncBackend and Go's net.Dialer
# default): disables Nagle's algorithm. Verified experimentally (by
# comparing against a plain socket that omits this) that skipping it
# costs a real ~40-50ms on TTFB against a live server — the classic
# Nagle/delayed-ACK interaction — not just noise. See docs/usage/hxprobe.md.
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
for option in socket_options or ():
sock.setsockopt(*option)
return _TimingStream(sock, trace)
def connect_unix_socket(self, path, timeout=None, socket_options=None):
raise NotImplementedError("hxprobe does not support UNIX sockets")
class _TimingTransport(httpx.HTTPTransport):
"""Subclasses httpx.HTTPTransport but replaces its connection pool's
network_backend, so handle_request()/close() (inherited, unchanged) still
get httpx's own httpcore-exception -> httpx-exception mapping for free."""
def __init__(
self, backend: httpcore.NetworkBackend, *, ssl_context: ssl.SSLContext, http2: bool
) -> None:
self._pool = httpcore.ConnectionPool(
ssl_context=ssl_context,
http1=True,
http2=http2,
network_backend=backend,
)
# ── measure ───────────────────────────────────────────────────────────────────
def _classify(exc: httpx.HTTPError) -> str:
"""Fallback classification for errors not raised by our own backend (e.g.
HTTP/2 stream resets, malformed responses) — "transfer" is the catch-all
bucket for post-connect failures that aren't a timeout or connect error."""
if isinstance(exc, httpx.TimeoutException):
return "timeout"
if isinstance(exc, httpx.ConnectError):
return "connect"
return "transfer"
def _unwrap(exc: BaseException) -> BaseException:
return exc.__cause__ if exc.__cause__ is not None else exc
def _fill_phases(r: Result, trace: _Trace, t_start: float, t_end: float) -> None:
r.dns = trace.dns
r.connect = trace.connect
r.tls = trace.tls
if trace.wrote_request is not None and trace.first_byte is not None:
r.ttfb = Phase(ms=(trace.first_byte - trace.wrote_request) * 1000, present=True)
if trace.first_byte is not None:
r.transfer = Phase(ms=(t_end - trace.first_byte) * 1000, present=True)
r.total = Phase(ms=(t_end - t_start) * 1000, present=True)
def _fill_verbose(
r: Result,
trace: _Trace,
*,
http_version: str = "",
redirect_count: int = 0,
headers: dict[str, str] | None = None,
) -> None:
"""Populate r.detail from whatever the trace captured. Called on both the
success and failure paths — e.g. resolved_ip/TLS info are known even when
a later phase (TTFB, transfer) is what actually failed."""
if r.detail is None:
return
d = r.detail
d.resolved_ip = trace.resolved_ip
d.http_version = http_version
d.redirect_count = redirect_count
if trace.tls.present:
d.tls_version = trace.tls_version
d.tls_cipher = trace.tls_cipher
d.tls_bits = trace.tls_bits
if trace.cert_peer:
d.cert = _parse_cert(trace.cert_peer, verified=True)
if headers:
d.headers = headers
def measure(raw_url: str, opts: Options | None = None) -> Result:
"""Probe raw_url via httpx and return a Result with per-phase timings.
Partial phases are preserved on failure; fail_phase is one of
dns/connect/timeout/tls/transfer/request. The underlying client
negotiates HTTP/2 (opts.http2, default True) and follows redirects
(opts.follow_redirects, default True) — matching Go's http.DefaultClient.
"""
if opts is None:
opts = Options()
r = Result(url=raw_url)
if opts.verbose:
r.detail = VerboseDetail()
parsed = urllib.parse.urlparse(raw_url)
scheme = parsed.scheme.lower()
if scheme not in ("http", "https"):
r.fail_phase = "request"
r.err = ValueError(f"unsupported scheme: {scheme!r}")
return r
trace = _Trace()
ssl_context = ssl.create_default_context()
transport = _TimingTransport(_TimingBackend(trace), ssl_context=ssl_context, http2=opts.http2)
status_code = 0
redirect_count = 0
http_version = ""
headers: dict[str, str] = {}
t_start = time.perf_counter()
try:
with httpx.Client(
transport=transport,
timeout=opts.timeout,
follow_redirects=opts.follow_redirects,
) as client:
with client.stream("GET", raw_url) as resp:
for _ in resp.iter_raw():
pass
status_code = resp.status_code
redirect_count = len(resp.history)
http_version = resp.http_version
if opts.verbose:
headers = dict(resp.headers)
except httpx.InvalidURL as exc:
r.fail_phase = "request"
r.err = exc
return r
except httpx.HTTPError as exc:
t_end = time.perf_counter()
_fill_phases(r, trace, t_start, t_end)
_fill_verbose(r, trace)
r.fail_phase = trace.fail_phase or _classify(exc)
r.err = _unwrap(exc)
return r
t_end = time.perf_counter()
_fill_phases(r, trace, t_start, t_end)
_fill_verbose(
r, trace, http_version=http_version, redirect_count=redirect_count, headers=headers
)
r.status_code = status_code
return r

31
hxprobe/pyproject.toml Normal file
View File

@@ -0,0 +1,31 @@
[project]
name = "hxprobe"
version = "0.1.0"
description = "Per-phase HTTP latency probe built on httpx — matches Go's http.DefaultClient (HTTP/2, redirects, connection pooling)"
requires-python = ">=3.11"
dependencies = [
"httpx[http2]>=0.28",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.8",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["hxprobe"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: tests that hit the live internet (excluded from the default run)",
]
[tool.ruff]
target-version = "py311"
line-length = 100

View File

260
hxprobe/tests/test_cli.py Normal file
View File

@@ -0,0 +1,260 @@
"""Hermetic tests for hxprobe.cli.run() — a standalone CLI (its own argparse,
exit codes, and text/JSON rendering, not shared with any other package).
These tests spot-check the core rendering paths (single URL, --fail, JSON,
DNS/connect failures) plus what's specific to hxprobe: the --no-http2/
--no-follow-redirects flags and that redirects/http_version are really
wired through end to end."""
import http.server
import io
import json
import os
import socket
import tempfile
import threading
import unittest
from hxprobe.cli import EXIT_CONNECT, EXIT_DNS, EXIT_HTTP, EXIT_OK, EXIT_USAGE, run
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _OKHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive is in play — always send Content-Length
def do_GET(self):
body = b"hello hxprobe"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
body = b"not found"
self.send_response(404)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class _RedirectHandler(http.server.BaseHTTPRequestHandler):
"""/redirect -> 302 to /landed; /landed -> 200."""
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "/landed")
self.send_header("Content-Length", "0")
self.end_headers()
else:
body = b"landed"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def _start_server(handler_class):
server = http.server.HTTPServer(("127.0.0.1", 0), handler_class)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
return server, server.server_address[1]
def _invoke(args: list[str]) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
code = run(args, out, err)
return code, out.getvalue(), err.getvalue()
class TestCLIBasics(unittest.TestCase):
def test_help_shows_hxprobe_prog_name(self):
code, out, err = _invoke(["-h"])
self.assertEqual(code, EXIT_OK)
self.assertIn("hxprobe", out)
def test_help_lists_protocol_flags(self):
code, out, err = _invoke(["-h"])
self.assertIn("--no-http2", out)
self.assertIn("--no-follow-redirects", out)
class TestCLISuccess(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
cls.redirect_server, cls.redirect_port = _start_server(_RedirectHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
cls.redirect_server.shutdown()
def _url(self, port=None, path=""):
return f"http://127.0.0.1:{port or self.ok_port}{path}"
def test_single_url_success(self):
code, out, _ = _invoke([self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("DNS lookup", out)
def test_fail_flag_on_404(self):
code, out, _ = _invoke(["--fail", self._url(self.nf_port)])
self.assertEqual(code, EXIT_HTTP)
self.assertIn("", out)
def test_redirect_followed_by_default(self):
code, out, _ = _invoke(["-v", self._url(self.redirect_port, "/redirect")])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("1 redirect", out)
def test_no_follow_redirects_flag(self):
code, out, _ = _invoke(
["--no-follow-redirects", self._url(self.redirect_port, "/redirect")]
)
self.assertEqual(code, EXIT_OK)
self.assertIn("302", out)
def test_verbose_shows_protocol_row(self):
code, out, _ = _invoke(["-v", self._url()])
self.assertIn("Protocol", out)
self.assertIn("HTTP/1.1", out)
def test_json_includes_http_version_and_redirect_count(self):
code, out, _ = _invoke(["--json", "-v", self._url(self.redirect_port, "/redirect")])
data = json.loads(out)
verbose = data[0]["verbose"]
self.assertEqual(verbose["redirect_count"], 1)
self.assertIn("http_version", verbose)
def test_no_http2_flag_forces_http1(self):
code, out, _ = _invoke(["--no-http2", "-v", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("HTTP/1.1", out)
class TestCLIFileInput(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
def _url(self):
return f"http://127.0.0.1:{self.ok_port}"
def test_reads_urls_from_file(self):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
f.write(f"# comment\n\n{self._url()}\n{self._url()}\n")
path = f.name
try:
code, out, _ = _invoke(["-f", path])
finally:
os.unlink(path)
self.assertEqual(code, EXIT_OK)
self.assertEqual(out.count("200"), 2)
def test_missing_file_is_usage_error(self):
code, out, err = _invoke(["-f", "/no/such/file/hxprobe-test"])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("cannot read", err)
def test_empty_file_is_usage_error(self):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
path = f.name
try:
code, out, err = _invoke(["-f", path])
finally:
os.unlink(path)
self.assertEqual(code, EXIT_USAGE)
self.assertIn("no URLs found", err)
def test_both_sources_given_is_usage_error(self):
code, out, err = _invoke(["-f", "/tmp/whatever", self._url()])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("cannot combine", err)
def test_no_sources_given_is_usage_error(self):
code, out, err = _invoke([])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("no URLs given", err)
class TestCLIFailures(unittest.TestCase):
def test_dns_failure(self):
code, out, _ = _invoke(["http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
self.assertIn("FAILED", out)
def test_connection_refused(self):
port = _free_port()
code, out, _ = _invoke([f"http://127.0.0.1:{port}"])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("FAILED", out)
class TestCLIRunSummary(unittest.TestCase):
"""The end-of-run footer: only shown for multi-URL runs, since the single
worst-code exit can't show the mix of failure classes behind it."""
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
def _url(self):
return f"http://127.0.0.1:{self.ok_port}"
def test_multi_url_mixed_shows_summary(self):
port = _free_port()
code, out, _ = _invoke([self._url(), f"http://127.0.0.1:{port}"])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("Summary: 2 URLs", out)
self.assertIn("1 ok", out)
self.assertIn("1 failed", out)
self.assertIn("connect : 1", out)
self.assertIn("→ exit 3", out)
def test_multi_url_all_ok_summary(self):
code, out, _ = _invoke([self._url(), self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("Summary: 2 URLs — 2 ok", out)
self.assertNotIn("", out)
def test_single_url_has_no_summary(self):
code, out, _ = _invoke([self._url()])
self.assertEqual(code, EXIT_OK)
self.assertNotIn("Summary:", out)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,122 @@
"""Integration tests against live internet services for hxprobe.
Run with:
make hx-test-integration
cd hxprobe && uv run pytest tests -m integration -v
Marked with `pytest.mark.integration` (see module-level `pytestmark` below)
so it's excluded from the default `hx-test`/`make test` gate, which runs
`pytest -m "not integration"` — these hit the real internet.
The point of this file is specifically hxprobe's headline capabilities: real
HTTP/2 negotiation and redirect-following.
"""
import io
import socket
import unittest
import pytest
from hxprobe.cli import EXIT_DNS, EXIT_OK, run
from hxprobe.probe import Options, measure
pytestmark = pytest.mark.integration
def _online() -> bool:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect(("8.8.8.8", 53))
s.close()
return True
except OSError:
return False
_NEEDS_NET = unittest.skipUnless(_online(), "no internet connectivity")
def _run(args: list[str]) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
code = run(args, out, err)
return code, out.getvalue(), err.getvalue()
@_NEEDS_NET
class TestHTTP2Negotiation(unittest.TestCase):
"""The headline Go-parity feature: real ALPN HTTP/2, not forced HTTP/1.1."""
def test_negotiates_h2_against_cloudflare(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIsNone(r.err, r.err)
self.assertEqual(r.detail.http_version, "HTTP/2")
def test_no_http2_flag_forces_http1(self):
code, out, _ = _run(["--no-http2", "-v", "https://example.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("HTTP/1.1", out)
self.assertNotIn("HTTP/2", out)
@_NEEDS_NET
class TestRedirectsLive(unittest.TestCase):
"""The other headline Go-parity feature: redirects followed by default."""
def test_follows_http_to_https_redirect(self):
code, out, _ = _run(["-v", "http://github.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("redirect", out)
def test_no_follow_redirects_reports_redirect_status(self):
code, out, _ = _run(["--no-follow-redirects", "http://github.com"])
self.assertEqual(code, EXIT_OK)
self.assertNotIn("redirect_count", out) # text mode never shows the raw key
self.assertTrue(any(code_str in out for code_str in ("301", "302", "307", "308")))
@_NEEDS_NET
class TestSuccess(unittest.TestCase):
def test_https_all_phases_present(self):
r = measure("https://example.com")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 200)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.tls.present)
self.assertTrue(r.ttfb.present)
self.assertTrue(r.transfer.present)
self.assertTrue(r.total.present)
def test_http_no_tls_phase(self):
r = measure("http://example.com")
self.assertIsNone(r.err, r.err)
self.assertFalse(r.tls.present)
def test_timings_are_positive(self):
r = measure("https://example.com")
for attr in ("dns", "connect", "tls", "ttfb", "transfer", "total"):
ph = getattr(r, attr)
if ph.present:
self.assertGreater(ph.ms, 0, f"{attr}.ms should be > 0")
@_NEEDS_NET
class TestDNSFailure(unittest.TestCase):
def test_invalid_tld_phase(self):
r = measure("http://no.such.host.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertIsNotNone(r.err)
self.assertFalse(r.dns.present)
self.assertFalse(r.connect.present)
self.assertTrue(r.total.present)
def test_cli_dns_failure_exit_code(self):
code, out, _ = _run(["http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
if __name__ == "__main__":
unittest.main()

256
hxprobe/tests/test_probe.py Normal file
View File

@@ -0,0 +1,256 @@
"""Hermetic tests for hxprobe.probe.measure(), including a redirect test
covering the Go-http.DefaultClient-parity feature (redirects followed by
default) that a plain raw-socket HTTP/1.1 client would not have."""
import http.server
import socket
import threading
import unittest
from hxprobe.probe import Options, VerboseDetail, measure
class _OKHandler(http.server.BaseHTTPRequestHandler):
# HTTP/1.1 (BaseHTTPRequestHandler defaults to 1.0) so hxprobe negotiates
# http_version="HTTP/1.1" instead of "HTTP/1.0". Keep-alive is then in
# play, so every response below sends an explicit Content-Length —
# without it, h11 has no way to detect body-end short of connection
# close, and the client hangs until it hits the request timeout.
protocol_version = "HTTP/1.1"
def _send_body(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._send_body(200, b"hello hxprobe")
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
body = b"not found"
self.send_response(404)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class _RedirectHandler(http.server.BaseHTTPRequestHandler):
"""/redirect -> 302 to /landed; /landed -> 200."""
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "/landed")
self.send_header("Content-Length", "0")
self.end_headers()
else:
body = b"landed"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def _start_server(handler_class):
server = http.server.HTTPServer(("127.0.0.1", 0), handler_class)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
return server, server.server_address[1]
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _black_hole_port() -> int:
"""Bind a port that accepts TCP but never sends any data (triggers a read timeout)."""
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(10)
port = srv.getsockname()[1]
conns: list = []
def _serve():
while True:
try:
conn, _ = srv.accept()
conns.append(conn)
except OSError:
break
threading.Thread(target=_serve, daemon=True).start()
return port
class TestMeasureSuccess(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def test_all_phases_present_on_success(self):
r = measure(f"http://127.0.0.1:{self.ok_port}")
self.assertIsNone(r.err)
self.assertEqual(r.fail_phase, "")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertFalse(r.tls.present) # http — no TLS
self.assertTrue(r.ttfb.present)
self.assertTrue(r.transfer.present)
self.assertTrue(r.total.present)
def test_status_code_404(self):
r = measure(f"http://127.0.0.1:{self.nf_port}")
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 404)
self.assertTrue(r.total.present)
def test_options_default_follows_redirects_and_http2(self):
opts = Options()
self.assertTrue(opts.follow_redirects)
self.assertTrue(opts.http2)
class TestMeasureFailures(unittest.TestCase):
def test_dns_failure(self):
r = measure("http://no.such.host.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertIsNotNone(r.err)
self.assertFalse(r.dns.present)
self.assertFalse(r.connect.present)
self.assertTrue(r.total.present)
def test_connection_refused(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}")
self.assertEqual(r.fail_phase, "connect")
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertFalse(r.ttfb.present)
self.assertTrue(r.total.present)
def test_ttfb_timeout(self):
port = _black_hole_port()
r = measure(f"http://127.0.0.1:{port}", Options(timeout=0.2))
self.assertEqual(r.fail_phase, "timeout")
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.total.present)
def test_bad_scheme(self):
r = measure("ftp://example.com")
self.assertEqual(r.fail_phase, "request")
self.assertIsNotNone(r.err)
self.assertFalse(r.total.present)
class TestRedirects(unittest.TestCase):
"""The Go-http.DefaultClient-parity feature: redirects followed by
default, with dns/connect/tls timed from the first hop only."""
@classmethod
def setUpClass(cls):
cls.server, cls.port = _start_server(_RedirectHandler)
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
def _url(self, path: str) -> str:
return f"http://127.0.0.1:{self.port}{path}"
def test_follows_redirect_by_default(self):
r = measure(self._url("/redirect"), Options(verbose=True))
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.detail.redirect_count, 1)
def test_no_follow_redirects_reports_302(self):
r = measure(self._url("/redirect"), Options(follow_redirects=False, verbose=True))
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 302)
self.assertEqual(r.detail.redirect_count, 0)
def test_dns_and_connect_present_across_redirect(self):
r = measure(self._url("/redirect"))
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.ttfb.present)
self.assertTrue(r.total.present)
class TestVerboseDetail(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
def _url(self):
return f"http://127.0.0.1:{self.ok_port}"
def test_detail_none_without_verbose(self):
r = measure(self._url())
self.assertIsNone(r.detail)
def test_detail_present_with_verbose(self):
r = measure(self._url(), Options(verbose=True))
self.assertIsInstance(r.detail, VerboseDetail)
def test_resolved_ip_set_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertEqual(r.detail.resolved_ip, "127.0.0.1")
def test_http_version_set_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertIn(r.detail.http_version, ("HTTP/1.1", "HTTP/2"))
def test_no_tls_fields_for_http(self):
r = measure(self._url(), Options(verbose=True))
self.assertEqual(r.detail.tls_version, "")
self.assertIsNone(r.detail.cert)
def test_headers_populated_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertIsInstance(r.detail.headers, dict)
self.assertTrue(len(r.detail.headers) > 0)
def test_ip_set_on_connect_fail(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}", Options(verbose=True))
self.assertEqual(r.fail_phase, "connect")
self.assertEqual(r.detail.resolved_ip, "127.0.0.1")
if __name__ == "__main__":
unittest.main()

225
hxprobe/uv.lock generated Normal file
View File

@@ -0,0 +1,225 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hxprobe"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx", extra = ["http2"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "httpx", extras = ["http2"], specifier = ">=0.28" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "ruff"
version = "0.15.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]