feat(py): step 1 — simple reachability checker (simple.py)
- 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 <noreply@anthropic.com>
This commit is contained in:
24
CHANGELOG.md
24
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)
|
## 2026-07-01 01:23 — Concurrency (Go, Step 7)
|
||||||
|
|
||||||
- Added `-c`/`--concurrency` flag: max URLs probed in parallel (0 = auto)
|
- Added `-c`/`--concurrency` flag: max URLs probed in parallel (0 = auto)
|
||||||
|
|||||||
35
Makefile
35
Makefile
@@ -1,6 +1,9 @@
|
|||||||
GO_DIR := go
|
GO_DIR := go
|
||||||
|
PY_DIR := python
|
||||||
BINARY := latprobe
|
BINARY := latprobe
|
||||||
|
PYTHON := python3.14
|
||||||
ARGS ?=
|
ARGS ?=
|
||||||
|
SITES ?= $(PY_DIR)/sites.txt
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
@@ -21,10 +24,10 @@ all: check build ## Run checks then build
|
|||||||
build: go-build ## Build binary (delegates to go-build)
|
build: go-build ## Build binary (delegates to go-build)
|
||||||
|
|
||||||
.PHONY: test
|
.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
|
.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
|
.PHONY: fmt
|
||||||
fmt: go-fmt ## Format source code (delegates to go-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)
|
vet: go-vet ## Run go vet (delegates to go-vet)
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: go-clean ## Remove build and coverage artifacts
|
clean: go-clean py-clean ## Remove build and coverage artifacts
|
||||||
|
|
||||||
# ── Go targets ────────────────────────────────────────────────────────────────
|
# ── Go targets ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -93,3 +96,29 @@ go-lint: ## go: run golangci-lint (must be installed)
|
|||||||
.PHONY: go-clean
|
.PHONY: go-clean
|
||||||
go-clean: ## go: remove binary and coverage artifacts
|
go-clean: ## go: remove binary and coverage artifacts
|
||||||
rm -f $(GO_DIR)/$(BINARY) $(GO_DIR)/coverage.out $(GO_DIR)/coverage.html
|
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
|
||||||
|
|||||||
47
docs/plans/2026-07-01-10-39-py-simple.md
Normal file
47
docs/plans/2026-07-01-10-39-py-simple.md
Normal file
@@ -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
|
||||||
79
docs/usage/py-simple.md
Normal file
79
docs/usage/py-simple.md
Normal file
@@ -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.
|
||||||
88
python/simple.py
Normal file
88
python/simple.py
Normal file
@@ -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:]))
|
||||||
6
python/sites.txt
Normal file
6
python/sites.txt
Normal file
@@ -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
|
||||||
0
python/tests/__init__.py
Normal file
0
python/tests/__init__.py
Normal file
Reference in New Issue
Block a user