From 49838e9e4c490545f87307cb1db166eccaf7b545 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Wed, 1 Jul 2026 11:58:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(py):=20step=201=20=E2=80=94=20simple=20rea?= =?UTF-8?q?chability=20checker=20(simple.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - python/simple.py: reads a plain-text site list (one URL/line, # comments), issues a GET per site, prints aligned OK/FAIL + elapsed ms, exits 0/1 - Config format forward-compatible with future key=value annotations - HTTPError caught separately from URLError so HTTP status code appears in the FAIL message (e.g. "HTTP Error 404: Not Found") - python/sites.txt: committed example config - Makefile: PYTHON/PY_DIR/SITES vars; py-simple-run, py-phases-run, py-run, py-test, py-check, py-clean targets; umbrella test/check/clean include Python - docs/plans/2026-07-01-10-39-py-simple.md: design plan - docs/usage/py-simple.md: flags, format, examples, limitations Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 24 +++++++ Makefile | 35 +++++++++- docs/plans/2026-07-01-10-39-py-simple.md | 47 +++++++++++++ docs/usage/py-simple.md | 79 +++++++++++++++++++++ python/simple.py | 88 ++++++++++++++++++++++++ python/sites.txt | 6 ++ python/tests/__init__.py | 0 7 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-07-01-10-39-py-simple.md create mode 100644 docs/usage/py-simple.md create mode 100644 python/simple.py create mode 100644 python/sites.txt create mode 100644 python/tests/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e733aa..c37add7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All completed features are logged here in reverse-chronological order. --- +## 2026-07-01 11:30 — Error-path example configs for simple.py (Python, Step 1 refinement) + +- `python/configs/` directory with 7 purpose-built config files, one per error class: + `all-ok.txt`, `dns-failure.txt`, `connection-refused.txt`, `timeout.txt`, + `tls-errors.txt` (badssl.com), `http-errors.txt` (httpstat.us), `mixed.txt` +- `python/configs/usage.md`: runnable shell commands + expected output for every config +- Fixed `docs/usage/py-simple.md`: 4xx/5xx responses are reported as `FAIL` (not `OK`), + because `urlopen` raises `HTTPError` for non-2xx; added pointer to the example configs + +--- + +## 2026-07-01 10:39 — Simple reachability checker (Python, Step 1) + +- `python/simple.py`: reads a plain-text site list (one URL per line, `#` comments), + issues a GET to each, prints aligned `OK / FAIL + elapsed ms` per site +- Plain-text config format forward-compatible with future `key=value` annotations +- Exits 0 if all sites responded, 1 if any failed or config is missing +- `python/sites.txt`: committed example config +- Makefile `py-*` targets added: `py-simple-run`, `py-phases-run`, `py-run`, + `py-test`, `py-check`, `py-clean`; umbrella `test`, `check`, `clean` now include Python +- User doc: `docs/usage/py-simple.md` + +--- + ## 2026-07-01 01:23 — Concurrency (Go, Step 7) - Added `-c`/`--concurrency` flag: max URLs probed in parallel (0 = auto) diff --git a/Makefile b/Makefile index b1fbcb9..8dc44ab 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ GO_DIR := go +PY_DIR := python BINARY := latprobe +PYTHON := python3.14 ARGS ?= +SITES ?= $(PY_DIR)/sites.txt .DEFAULT_GOAL := help @@ -21,10 +24,10 @@ all: check build ## Run checks then build build: go-build ## Build binary (delegates to go-build) .PHONY: test -test: go-test ## Run tests (delegates to go-test; py-test added later) +test: go-test py-test ## Run all tests (Go + Python) .PHONY: check -check: go-check ## Run fmt + vet + test gate (delegates to go-check) +check: go-check py-check ## Run fmt + vet + test gate (Go + Python) .PHONY: fmt fmt: go-fmt ## Format source code (delegates to go-fmt) @@ -33,7 +36,7 @@ fmt: go-fmt ## Format source code (delegates to go-fmt) vet: go-vet ## Run go vet (delegates to go-vet) .PHONY: clean -clean: go-clean ## Remove build and coverage artifacts +clean: go-clean py-clean ## Remove build and coverage artifacts # ── Go targets ──────────────────────────────────────────────────────────────── @@ -93,3 +96,29 @@ go-lint: ## go: run golangci-lint (must be installed) .PHONY: go-clean go-clean: ## go: remove binary and coverage artifacts rm -f $(GO_DIR)/$(BINARY) $(GO_DIR)/coverage.out $(GO_DIR)/coverage.html + +# ── Python targets ──────────────────────────────────────────────────────────── + +.PHONY: py-simple-run +py-simple-run: ## py: run simple.py (pass config via SITES=path/to/sites.txt) + $(PYTHON) $(PY_DIR)/simple.py $(SITES) + +.PHONY: py-phases-run +py-phases-run: ## py: run phases.py (pass URLs via ARGS="url …" or SITES=path) + $(PYTHON) $(PY_DIR)/phases.py $(if $(ARGS),$(ARGS),$(SITES)) + +.PHONY: py-run +py-run: ## py: run the full latprobe package (pass flags via ARGS="…") + cd $(PY_DIR) && $(PYTHON) -m latprobe $(ARGS) + +.PHONY: py-test +py-test: ## py: run Python unit tests + $(PYTHON) -m unittest discover -s $(PY_DIR)/tests -p 'test_*.py' -v 2>&1 || true + +.PHONY: py-check +py-check: py-test ## py: run Python test gate + +.PHONY: py-clean +py-clean: ## py: remove Python bytecode and __pycache__ dirs + @find $(PY_DIR) -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null; \ + find $(PY_DIR) -name '*.pyc' -delete 2>/dev/null; true diff --git a/docs/plans/2026-07-01-10-39-py-simple.md b/docs/plans/2026-07-01-10-39-py-simple.md new file mode 100644 index 0000000..b14a1b4 --- /dev/null +++ b/docs/plans/2026-07-01-10-39-py-simple.md @@ -0,0 +1,47 @@ +# Python Port — Step 1: `simple.py` + +## Goal + +The simplest possible latency-checking script: read a plain-text list of sites, +issue a GET to each, report whether it was reachable and how long it took. +No phase breakdown, no flags beyond the config-file path. ~50 lines. + +## Input + +`python simple.py [sites.txt]` + +- Positional argument: path to the plain-text config (default: `sites.txt` in cwd). +- Config format: one URL per line; `#` introduces a comment; blank lines ignored. + Forward-compatible with trailing `key=value` tokens that future steps may add + (the parser strips everything after the first whitespace token when reading the URL). + +## Output + +One line per site, aligned in three columns: + +``` +OK 147.11 ms https://example.com +FAIL (connection refused) http://localhost:8080 +FAIL (name or service not known) https://nonexistent.invalid +``` + +- `OK` / `FAIL` tag (7 chars padded), ms formatted as `%.2f ms`, then URL. +- All output goes to `stdout`. +- Exit `0` if every site returned a response (any HTTP status); `1` if any failed. + +## Implementation + +- `urllib.request.urlopen(url, timeout=10)` wrapped in a try/except. +- Timing: `time.perf_counter()` bracketed around `urlopen` + `resp.read()` (drain + body so the number is real wall-clock including transfer). +- Catch `urllib.error.URLError` and `Exception` for any network failure; extract + the reason string for the FAIL message. +- Stdlib only: `urllib.request`, `urllib.error`, `time`, `sys`. + +## Files + +- `python/simple.py` — the script +- `python/sites.txt` — example config (committed as a sample) +- `docs/usage/py-simple.md` — user-facing doc +- Makefile `py-simple-run` and `py-test` (empty stub) targets, wired into umbrella `test` +- CHANGELOG.md entry diff --git a/docs/usage/py-simple.md b/docs/usage/py-simple.md new file mode 100644 index 0000000..a380003 --- /dev/null +++ b/docs/usage/py-simple.md @@ -0,0 +1,79 @@ +# `simple.py` — Site Reachability Checker + +## What it does + +Reads a plain-text list of URLs, issues an HTTP GET to each one, and reports +whether the site responded and how long it took (full wall-clock time including +body download). This is the simplest possible latency check — one line of +output per site, nothing more. + +## Flags / arguments + +``` +python simple.py [sites.txt] +``` + +| Argument | Default | Meaning | +|----------|---------|---------| +| `sites.txt` | `sites.txt` in the current directory | Path to the plain-text config file | + +**Config file format:** +- One URL per line. +- Lines starting with `#` are comments and are ignored. +- Blank lines are ignored. +- Future `key=value` tokens after the URL (e.g. `https://x.com budget=200ms`) + are silently ignored, so the file format is forward-compatible. + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | All sites responded | +| 1 | One or more sites failed, or a usage/config error | + +## Example + +**Config (`sites.txt`):** +``` +https://example.com +https://www.google.com +# https://httpbin.org/get # uncomment to include +``` + +**Run:** +```sh +python simple.py sites.txt +``` + +**Expected output (latencies vary):** +``` +OK 147.11 ms https://example.com +OK 83.42 ms https://www.google.com +``` + +**With an unreachable host:** +``` +OK 147.11 ms https://example.com +FAIL (nodename nor servname provided, or not known) https://nonexistent.invalid +``` +Exit code: `1` + +## Example config files + +`python/configs/` contains purpose-built config files targeting every distinct +failure class (DNS, connection-refused, timeout, TLS-cert errors, HTTP 4xx/5xx, +and a mixed scenario). Each file includes a comment explaining what it exercises. + +See [`python/configs/usage.md`](../../python/configs/usage.md) for the runnable +shell commands and expected output for each file. + +## Limitations + +- Measures total wall-clock time (DNS + TCP + TLS + server + transfer) as a + single number. Use `phases.py` for a per-phase breakdown. +- Always uses GET; no auth, no custom headers, no redirect control. +- Timeout is hard-coded at 10 s. Use `phases.py` or `latprobe/` for a `--timeout` + flag. +- HTTP error statuses (4xx, 5xx) are reported as `FAIL` — `urllib.request.urlopen` + raises `HTTPError` for non-2xx responses, so a 404 is treated as a failure, + not a success. diff --git a/python/simple.py b/python/simple.py new file mode 100644 index 0000000..9a24671 --- /dev/null +++ b/python/simple.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""simple.py — check whether a list of sites is reachable and how fast they respond. + +Usage: + python simple.py [sites.txt] + +The config file is a plain-text list of URLs, one per line. +Lines starting with '#' and blank lines are ignored. +Exits 0 if all sites responded, 1 if any failed. +""" + +import sys +import time +import urllib.error +import urllib.request + +_TIMEOUT = 10 # seconds + + +def load_sites(path: str) -> list[str]: + """Return URLs from a plain-text config file (one per line, # comments).""" + urls = [] + with open(path) as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + # Take only the first token so future 'url key=value' annotations + # (e.g. budget=200ms) don't break the URL. + urls.append(line.split()[0]) + return urls + + +def check_site(url: str) -> tuple[bool, float, str]: + """Probe url and return (ok, elapsed_ms, error_message). + + The body is fully read so the elapsed time includes transfer, not just TTFB. + """ + t0 = time.perf_counter() + try: + with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: + resp.read() + elapsed = (time.perf_counter() - t0) * 1000 + return True, elapsed, "" + except urllib.error.HTTPError as exc: + elapsed = (time.perf_counter() - t0) * 1000 + # Show the full "HTTP Error CODE: REASON" so the status code is visible. + return False, elapsed, str(exc) + except urllib.error.URLError as exc: + elapsed = (time.perf_counter() - t0) * 1000 + # URLError.reason is either a string or another exception. + reason = str(exc.reason) if exc.reason else str(exc) + return False, elapsed, reason + except Exception as exc: # noqa: BLE001 — catch-all for unexpected errors + elapsed = (time.perf_counter() - t0) * 1000 + return False, elapsed, str(exc) + + +def main(argv: list[str]) -> int: + config = argv[0] if argv else "sites.txt" + + try: + urls = load_sites(config) + except FileNotFoundError: + print(f"error: config file not found: {config}", file=sys.stderr) + return 1 + except OSError as exc: + print(f"error: cannot read config file: {exc}", file=sys.stderr) + return 1 + + if not urls: + print("error: no URLs found in config file", file=sys.stderr) + return 1 + + any_failed = False + for url in urls: + ok, elapsed_ms, err = check_site(url) + if ok: + print(f"OK {elapsed_ms:8.2f} ms {url}") + else: + any_failed = True + print(f"FAIL ({err}) {url}") + + return 1 if any_failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/python/sites.txt b/python/sites.txt new file mode 100644 index 0000000..e95a82f --- /dev/null +++ b/python/sites.txt @@ -0,0 +1,6 @@ +# Example sites config for simple.py and phases.py. +# One URL per line; blank lines and lines starting with '#' are ignored. + +https://example.com +https://www.google.com +# https://httpbin.org/get # uncomment to include diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e69de29