Add QCEW CSV module, ECEC breakdown, response caching + typed errors
Item 1 — QCEW county/industry detail: - new bls_client/qcew.py: thin client over the QCEW Open Data CSV service (area/industry/size slices; no API key, no quota), with filter_rows helper - covers the county/industry detail the timeseries API can't reach Item 2 — ECEC benefit breakdown: - new series.ecec() builder from the authoritative cm.estimate decode table - real helpers: wages, total benefits, paid leave, supplemental pay, health insurance, retirement & savings, legally required + ecec_dashboard() - FIX: ecec_total_benefits was CMU1036... (education/health industries only); correct all-civilian total benefits is CMU1030000000000D Item 3 — caching + typed errors: - bls_client/cache.py FileCache (on-disk, TTL); BLSClient(cache=True), queries_used counter (cache hits don't spend quota) - bls_client/errors.py: BLSError / BLSQuotaError / BLSRequestError; quota exhaustion now raises a clear BLSQuotaError instead of a generic RuntimeError Tests: 119 offline (+24) incl. ECEC goldens, QCEW fixture parse, cache/quota behavior; live smoke extended to ECEC + QCEW. Helper sweep: 96/96 live. README: caching/QCEW usage, updated coverage + limitations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
bls_client/cache.py
Normal file
49
bls_client/cache.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""A tiny on-disk response cache for BLS requests.
|
||||
|
||||
BLS data updates monthly/quarterly, so caching identical requests avoids
|
||||
re-spending the 500/day quota on reruns (reports, the explorer, test sweeps).
|
||||
Pure stdlib — no extra dependency.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FileCache:
|
||||
"""JSON file cache keyed by request payload, with a TTL (seconds)."""
|
||||
|
||||
def __init__(self, cache_dir=None, ttl=86400):
|
||||
self.dir = Path(cache_dir or os.path.expanduser("~/.cache/bls"))
|
||||
self.ttl = ttl
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def key(payload: dict) -> str:
|
||||
"""Deterministic key from a request payload (API key excluded, series sorted)."""
|
||||
relevant = {k: v for k, v in payload.items() if k != "registrationkey"}
|
||||
if isinstance(relevant.get("seriesid"), list):
|
||||
relevant["seriesid"] = sorted(relevant["seriesid"])
|
||||
blob = json.dumps(relevant, sort_keys=True, default=str)
|
||||
return hashlib.sha256(blob.encode()).hexdigest()
|
||||
|
||||
def _path(self, key: str) -> Path:
|
||||
return self.dir / f"{key}.json"
|
||||
|
||||
def get(self, key: str):
|
||||
p = self._path(key)
|
||||
if not p.exists():
|
||||
return None
|
||||
if self.ttl is not None and (time.time() - p.stat().st_mtime) > self.ttl:
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def set(self, key: str, value) -> None:
|
||||
try:
|
||||
self._path(key).write_text(json.dumps(value))
|
||||
except OSError:
|
||||
pass # caching is best-effort; never fail a fetch over it
|
||||
Reference in New Issue
Block a user