web-fetcher/web_fetcher/periods.py
tegwick 96fe5b022b Release 0.2.0: replay, source enforcement, period rule, selective retry (WEBFETCH-WP-0002)
- --replay DIR / replay_dir: offline replay from the cache, hash-verified
- ok values without source become error; Last-Modified recorded as published
- latest period by parsed order (web_fetcher/periods.py); mixed formats refused
- non-numeric values not_found unless value_type: string
- retry only connection errors, timeouts, 429/5xx; honour Retry-After
- opt-in live smoke test; 18 offline tests
- CHANGELOG, README install docs, SCOPE update, capability D2/A3/C2/R2
- WEBFETCH-WP-0003 (repo hygiene) added

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 153021@bnt-lap001
Assistant-Session: 46917be4-6400-4106-b8b7-61953a0caf24
2026-10-19 14:08:35 +02:00

62 lines
2.5 KiB
Python

"""Period ordering for "latest value" selection.
A source's period labels are only compared when they share one recognised
format; mixed or unrecognised formats are an error, never a silent string
comparison. Spec field `period_format` pins the format ("auto" by default);
"string" opts in to plain lexicographic order.
"""
from __future__ import annotations
import re
from typing import Callable
_FORMATS: dict[str, tuple[re.Pattern[str], Callable[[re.Match[str]], tuple[int, ...]]]] = {
"year": (re.compile(r"^(\d{4})$"), lambda m: (int(m[1]),)),
"month": (re.compile(r"^(\d{4})-(\d{2})$"), lambda m: (int(m[1]), int(m[2]))),
"date": (re.compile(r"^(\d{4})-(\d{2})-(\d{2})$"), lambda m: (int(m[1]), int(m[2]), int(m[3]))),
"quarter": (re.compile(r"^(\d{4})-?Q([1-4])$"), lambda m: (int(m[1]), int(m[2]))),
"semester": (re.compile(r"^(\d{4})-?S([12])$"), lambda m: (int(m[1]), int(m[2]))),
"week": (re.compile(r"^(\d{4})-?W(\d{2})$"), lambda m: (int(m[1]), int(m[2]))),
# Fixed-width digit labels such as YYYYMM or Census BTOS YYYYPP.
"digits": (re.compile(r"^(\d+)$"), lambda m: (len(m[1]), int(m[1]))),
}
class PeriodError(ValueError):
pass
def detect(label: str) -> str | None:
label = label.strip()
for name, (pat, _) in _FORMATS.items():
if pat.match(label):
return name
return None
def sort_key(label: str, fmt: str) -> tuple[int, ...] | str:
if fmt == "string":
return label
pat, key = _FORMATS[fmt]
m = pat.match(label.strip())
if m is None:
raise PeriodError(f"period {label!r} does not match format {fmt!r}")
return key(m)
def order(labels: list[str], fmt: str = "auto") -> tuple[str, list[str]]:
"""Return (format, labels newest first). Raises PeriodError for mixed formats."""
if fmt == "auto":
found = {detect(lbl) for lbl in labels}
if None in found:
bad = [lbl for lbl in labels if detect(lbl) is None][:3]
raise PeriodError(f"unrecognised period labels {bad}; set period_format")
if len(found) > 1:
raise PeriodError(f"mixed period formats {sorted(found)}; set period_format")
fmt = found.pop() if found else "string"
if fmt == "digits" and len({len(lbl.strip()) for lbl in labels}) > 1:
raise PeriodError("digit period labels of different widths; set period_format")
elif fmt != "string" and fmt not in _FORMATS:
raise PeriodError(f"unknown period_format {fmt!r}")
return fmt, sorted(labels, key=lambda lbl: sort_key(lbl, fmt), reverse=True)