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:
2026-06-22 10:38:12 -04:00
parent 881d62cf32
commit 9e2c55c568
14 changed files with 499 additions and 44 deletions

View File

@ -42,6 +42,12 @@ GOLDEN = [
(S.productivity, dict(), "PRS85006092"),
(S.productivity, dict(sector="manufacturing", measure="unit_labor_cost"), "PRS30006112"),
(S.productivity, dict(sector="business"), "PRS84006092"),
# ECEC — 17 chars, CMU + owner(1) + component(2) + 10 zeros + datatype
(S.ecec, dict(), "CMU1010000000000D"),
(S.ecec, dict(component="health_insurance"), "CMU1150000000000D"),
(S.ecec, dict(component="retirement_and_savings"), "CMU1180000000000D"),
(S.ecec, dict(component="total_benefits", owner="private"), "CMU2030000000000D"),
(S.ecec, dict(component="total_compensation", owner="state_local_gov"), "CMU3010000000000D"),
]
@ -77,6 +83,13 @@ def test_productivity_is_11_chars():
assert sid.startswith("PRS")
def test_ecec_is_17_chars_cost_per_hour():
for comp in ("total_compensation", "health_insurance", "retirement_and_savings"):
sid = S.ecec(comp)
assert len(sid) == 17
assert sid.startswith("CMU") and sid.endswith("D")
def test_laus_state_is_20_chars():
assert len(S.laus_state(48, "rate")) == 20