55 lines
1.6 KiB
Python
55 lines
1.6 KiB
Python
"""Tiny, dependency-free helpers (stdlib only)."""
|
|
from __future__ import annotations
|
|
import json
|
|
import urllib.request
|
|
import urllib.error
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
class HttpError(Exception):
|
|
def __init__(self, status: int, url: str, body: str = ""):
|
|
super().__init__(f"HTTP {status} for {url}")
|
|
self.status, self.url, self.body = status, url, body
|
|
|
|
|
|
def default_get(url: str, headers: dict | None = None, timeout: int = 15):
|
|
"""Return (status, text). The single network primitive — injected in tests."""
|
|
req = urllib.request.Request(url, headers=headers or {})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return getattr(r, "status", r.getcode()), r.read().decode("utf-8")
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read().decode("utf-8", "replace")
|
|
|
|
|
|
def get_json(url: str, headers: dict | None = None, getter=default_get):
|
|
status, body = getter(url, headers)
|
|
if status != 200:
|
|
raise HttpError(status, url, body)
|
|
return json.loads(body)
|
|
|
|
|
|
def dig(data, path: str):
|
|
"""Read a dotted path out of nested dicts: dig(d, 'a.b') -> d['a']['b'] or None."""
|
|
cur = data
|
|
for part in path.split("."):
|
|
if isinstance(cur, dict):
|
|
cur = cur.get(part)
|
|
else:
|
|
return None
|
|
return cur
|
|
|
|
|
|
def utcnow() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def fmt(v) -> str:
|
|
if v is None:
|
|
return "—" # em dash
|
|
try:
|
|
f = float(v)
|
|
return str(int(f)) if f.is_integer() else str(round(f, 2))
|
|
except (TypeError, ValueError):
|
|
return str(v)
|