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:
1
hxprobe/.python-version
Normal file
1
hxprobe/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
55
hxprobe/Makefile
Normal file
55
hxprobe/Makefile
Normal 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
38
hxprobe/README.md
Normal 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
703
hxprobe/USAGE.md
Normal 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/`.
|
||||
7
hxprobe/configs/all-ok.txt
Normal file
7
hxprobe/configs/all-ok.txt
Normal 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
|
||||
7
hxprobe/configs/connection-refused.txt
Normal file
7
hxprobe/configs/connection-refused.txt
Normal 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
|
||||
9
hxprobe/configs/dns-failure.txt
Normal file
9
hxprobe/configs/dns-failure.txt
Normal 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.
|
||||
22
hxprobe/configs/http-errors.txt
Normal file
22
hxprobe/configs/http-errors.txt
Normal 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
|
||||
67
hxprobe/configs/large-mixed.txt
Normal file
67
hxprobe/configs/large-mixed.txt
Normal 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
23
hxprobe/configs/mixed.txt
Normal 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
|
||||
16
hxprobe/configs/timeout.txt
Normal file
16
hxprobe/configs/timeout.txt
Normal 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/
|
||||
22
hxprobe/configs/tls-errors.txt
Normal file
22
hxprobe/configs/tls-errors.txt
Normal 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/
|
||||
7
hxprobe/hxprobe/__init__.py
Normal file
7
hxprobe/hxprobe/__init__.py
Normal 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.
|
||||
"""
|
||||
5
hxprobe/hxprobe/__main__.py
Normal file
5
hxprobe/hxprobe/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .cli import run
|
||||
|
||||
sys.exit(run(sys.argv[1:], sys.stdout, sys.stderr))
|
||||
53
hxprobe/hxprobe/aggregate.py
Normal file
53
hxprobe/hxprobe/aggregate.py
Normal 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
523
hxprobe/hxprobe/cli.py
Normal 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
|
||||
14
hxprobe/hxprobe/duration.py
Normal file
14
hxprobe/hxprobe/duration.py
Normal 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
458
hxprobe/hxprobe/probe.py
Normal 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
31
hxprobe/pyproject.toml
Normal 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
|
||||
0
hxprobe/tests/__init__.py
Normal file
0
hxprobe/tests/__init__.py
Normal file
260
hxprobe/tests/test_cli.py
Normal file
260
hxprobe/tests/test_cli.py
Normal 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()
|
||||
122
hxprobe/tests/test_integration.py
Normal file
122
hxprobe/tests/test_integration.py
Normal 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
256
hxprobe/tests/test_probe.py
Normal 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
225
hxprobe/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user