Files
bls-data/bls_client/qcew.py
Dave Boyd 0d0d6dabe2 Add transient-network retry with exponential backoff
- bls_client/retry.py: with_retries() retries timeouts/connection drops and
  429/5xx with exponential backoff; honors Retry-After; never retries 4xx or
  BLSQuotaError (those won't fix themselves)
- BLSClient(retries=2, backoff=0.5) wraps the data POST and discovery GETs;
  qcew CSV fetch wrapped too. retries=0 disables.
- tests/test_retry.py: 6 offline tests (injectable sleep, no real delays)
- README: retry behavior documented; drop the now-resolved limitation

Offline suite 125 passing; live smoke green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:49:18 -04:00

78 lines
3.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""QCEW Open Data Access — county/industry employment & wages.
The BLS *timeseries* API only carries QCEW national/state totals. The full
QCEW detail (every county, every NAICS industry, establishment counts and
wages) lives behind a separate CSV service that needs no API key and counts
against no quota:
https://data.bls.gov/cew/data/api/{year}/{qtr}/{slice}/{code}.csv
This module is a thin wrapper over that service. It is deliberately separate
from BLSClient (different API, CSV not JSON, no key). Each call returns a list
of plain dict rows (the CSV columns), so it composes with csv/pandas directly.
Quarter argument: 1-4 for a specific quarter, or "a" for annual averages.
Area FIPS examples: "US000" national, "11000" DC (state), "11001" a county,
metro codes start with "C". Industry codes are NAICS (e.g. "10" = all
industries, "722" = food services, "622" = hospitals).
"""
import csv
import io
import requests
from .retry import with_retries
QCEW_BASE = "https://data.bls.gov/cew/data/api"
_HEADERS = {"User-Agent": "bls-data-library (https://gitea.doesworks.net/giteaadmin/bls-data)"}
# A few common columns, for reference / convenience:
# area_fips, own_code, industry_code, agglvl_code, size_code, year, qtr,
# annual_avg_estabs, annual_avg_emplvl, total_annual_wages, annual_avg_wkly_wage,
# avg_annual_pay (quarterly slices use month1_emplvl/qtrly_estabs/total_qtrly_wages, etc.)
OWNERSHIP = {
"total": "0", "federal": "1", "state": "2", "local": "3",
"private": "5", "government": "8",
}
def _fetch_csv(url: str, retries=2, backoff=0.5) -> list[dict]:
r = with_retries(lambda: requests.get(url, timeout=60, headers=_HEADERS),
retries=retries, backoff=backoff)
r.raise_for_status()
return parse_csv(r.text)
def parse_csv(text: str) -> list[dict]:
"""Parse QCEW CSV text into a list of row dicts (BLS quotes every field)."""
return list(csv.DictReader(io.StringIO(text)))
def area(area_fips: str, year: int, qtr="a") -> list[dict]:
"""Every industry × ownership for one area (county/state/metro/national)."""
return _fetch_csv(f"{QCEW_BASE}/{year}/{qtr}/area/{area_fips}.csv")
def industry(industry_code: str, year: int, qtr="a") -> list[dict]:
"""One NAICS industry across all areas."""
return _fetch_csv(f"{QCEW_BASE}/{year}/{qtr}/industry/{industry_code}.csv")
def size(size_code: str, year: int) -> list[dict]:
"""Establishment-size breakdown (size data is published for Q1 only)."""
return _fetch_csv(f"{QCEW_BASE}/{year}/1/size/{size_code}.csv")
def filter_rows(rows, own_code=None, industry_code=None, agglvl_code=None) -> list[dict]:
"""Filter QCEW rows by ownership / industry / aggregation-level code."""
out = rows
if own_code is not None:
own = OWNERSHIP.get(own_code, own_code)
out = [r for r in out if r.get("own_code") == own]
if industry_code is not None:
out = [r for r in out if r.get("industry_code") == industry_code]
if agglvl_code is not None:
out = [r for r in out if r.get("agglvl_code") == agglvl_code]
return out