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:
@ -10,14 +10,32 @@ Usage:
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
from .cache import FileCache
|
||||
from .errors import BLSError, BLSRequestError, BLSQuotaError
|
||||
|
||||
|
||||
class BLSClient:
|
||||
BASE_URL = "https://api.bls.gov/publicAPI/v2"
|
||||
MAX_SERIES_PER_CALL = 50
|
||||
MAX_YEARS_PER_CALL = 20
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
def __init__(self, api_key: str, cache=False, cache_dir=None, cache_ttl=86400):
|
||||
"""
|
||||
Args:
|
||||
api_key: BLS registration key.
|
||||
cache: True to enable the default on-disk cache (~/.cache/bls),
|
||||
or pass a custom cache object with get(key)/set(key, value).
|
||||
cache_dir: Override the default cache directory.
|
||||
cache_ttl: Cache entry lifetime in seconds (default 1 day).
|
||||
"""
|
||||
self.api_key = api_key
|
||||
if cache is True:
|
||||
self.cache = FileCache(cache_dir, cache_ttl)
|
||||
elif cache:
|
||||
self.cache = cache # caller-supplied cache object
|
||||
else:
|
||||
self.cache = None
|
||||
self.queries_used = 0 # network POSTs spent this session (cache hits don't count)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core fetch
|
||||
@ -64,23 +82,44 @@ class BLSClient:
|
||||
"calculations": calculations,
|
||||
"annualaverage": annual_average,
|
||||
}
|
||||
r = requests.post(
|
||||
f"{self.BASE_URL}/timeseries/data/",
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if body["status"] != "REQUEST_SUCCEEDED":
|
||||
raise RuntimeError(f"BLS API error: {body['message']}")
|
||||
for s in body["Results"]["series"]:
|
||||
results[s["seriesID"]] = {
|
||||
"data": s["data"],
|
||||
"catalog": s.get("catalog", {}),
|
||||
}
|
||||
|
||||
cache_key = self.cache.key(payload) if self.cache else None
|
||||
batch_result = self.cache.get(cache_key) if cache_key else None
|
||||
|
||||
if batch_result is None:
|
||||
batch_result = self._fetch_batch(payload)
|
||||
if cache_key:
|
||||
self.cache.set(cache_key, batch_result)
|
||||
|
||||
results.update(batch_result)
|
||||
|
||||
return results
|
||||
|
||||
def _fetch_batch(self, payload: dict) -> dict:
|
||||
"""Execute one (uncached) API call and return {series_id: {data, catalog}}."""
|
||||
r = requests.post(f"{self.BASE_URL}/timeseries/data/", json=payload, timeout=30)
|
||||
r.raise_for_status()
|
||||
self.queries_used += 1
|
||||
body = r.json()
|
||||
|
||||
status = body.get("status")
|
||||
if status != "REQUEST_SUCCEEDED":
|
||||
msgs = body.get("message", [])
|
||||
text = " ".join(msgs) if isinstance(msgs, list) else str(msgs)
|
||||
low = text.lower()
|
||||
if status == "REQUEST_NOT_PROCESSED" and ("threshold" in low or "exceed" in low):
|
||||
raise BLSQuotaError(
|
||||
f"BLS daily query limit reached: {text}", status=status, messages=msgs
|
||||
)
|
||||
raise BLSRequestError(
|
||||
f"BLS API request failed ({status}): {text}", status=status, messages=msgs
|
||||
)
|
||||
|
||||
return {
|
||||
s["seriesID"]: {"data": s["data"], "catalog": s.get("catalog", {})}
|
||||
for s in body["Results"]["series"]
|
||||
}
|
||||
|
||||
def fetch_latest(
|
||||
self,
|
||||
series_ids: list[str] | str,
|
||||
|
||||
Reference in New Issue
Block a user