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:
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:]))
|
||||
Reference in New Issue
Block a user