Add pyproject.toml packaging and pytest suite
- pyproject.toml: setuptools build, deps (requests), dev extra (pytest), pytest config with opt-in `live` marker - tests/: 95 offline tests locking every series-ID encoding against known-good IDs (regression guard for the JOLTS/OES/ECI/productivity/QCEW fixes), plus a live API smoke test behind `-m live` - README: install-as-package + test instructions; drop the now-resolved "no packaging"/"no tests" limitations - gitignore build/test artifacts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Shared test fixtures and path setup.
|
||||
|
||||
Lets the suite run straight from the repo (`pytest`) without installing the
|
||||
package, while also working fine when bls_client is pip-installed.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
74
tests/test_live_smoke.py
Normal file
74
tests/test_live_smoke.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Live smoke test — hits the real BLS API.
|
||||
|
||||
Opt-in only: deselected by default (see addopts in pyproject.toml). Run with:
|
||||
pytest -m live
|
||||
Requires network and a BLS API key (BLS_API_KEY env var, or config.py).
|
||||
|
||||
Guards against silent series-ID drift: BLS occasionally retires codes (e.g. the
|
||||
2010->2018 SOC change), which unit tests alone can't catch.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from bls_client import BLSClient
|
||||
from bls_client.queries import employment as E, prices as P, wages as W, productivity as PR
|
||||
|
||||
|
||||
def _api_key():
|
||||
key = os.environ.get("BLS_API_KEY")
|
||||
if key:
|
||||
return key
|
||||
try:
|
||||
from config import BLS_API_KEY # local, gitignored
|
||||
if BLS_API_KEY and BLS_API_KEY != "YOUR_KEY_HERE":
|
||||
return BLS_API_KEY
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
pytestmark = pytest.mark.live
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
key = _api_key()
|
||||
if not key:
|
||||
pytest.skip("no BLS_API_KEY available")
|
||||
return BLSClient(key)
|
||||
|
||||
|
||||
# One representative, previously-broken helper per repaired survey.
|
||||
REPAIRED = {
|
||||
"JOLTS": E.jolts_dashboard(), # dict of 5
|
||||
"OES wage": W.occupation_annual_median_wage("151252"),
|
||||
"OES employ": W.all_occupations_employment(),
|
||||
"ECI": W.eci_total_compensation(),
|
||||
"Productivity": PR.business_output_per_hour(),
|
||||
"QCEW": E.qcew_national_private(),
|
||||
"PPI final": P.ppi_final_demand(),
|
||||
"ECEC": W.ecec_total_benefits(),
|
||||
}
|
||||
|
||||
|
||||
def _flatten(v):
|
||||
return list(v.values()) if isinstance(v, dict) else [v]
|
||||
|
||||
|
||||
def test_repaired_surveys_return_data(client):
|
||||
"""Every repaired survey must return at least one real observation."""
|
||||
all_ids = []
|
||||
for ids in REPAIRED.values():
|
||||
all_ids.extend(_flatten(ids))
|
||||
res = client.fetch(all_ids, 2024, 2025)
|
||||
missing = [sid for sid in all_ids if not res.get(sid, {}).get("data")]
|
||||
assert not missing, f"no data returned for: {missing}"
|
||||
|
||||
|
||||
def test_headline_payrolls_plausible(client):
|
||||
res = client.fetch_latest(E.nonfarm_payrolls(), years=1)
|
||||
obs = client.latest_obs(next(iter(res.values())))
|
||||
assert obs is not None
|
||||
# US nonfarm payrolls are ~150,000 (thousands); sanity bound, not exact.
|
||||
assert 100_000 < float(obs["value"]) < 200_000
|
||||
84
tests/test_query_helpers.py
Normal file
84
tests/test_query_helpers.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Unit tests for the pre-built query helpers (offline).
|
||||
|
||||
Asserts the high-value helpers emit the exact known-good IDs, and that every
|
||||
zero-argument helper across all modules emits a structurally valid series ID
|
||||
(correct survey prefix + length). No network.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from bls_client.queries import employment as E, prices as P, wages as W, productivity as PR
|
||||
|
||||
GOLDEN = [
|
||||
(E.nonfarm_payrolls, "CES0000000001"),
|
||||
(E.qcew_national_private, "ENUUS00010510"),
|
||||
(E.qcew_dc_private, "ENU1100010510"),
|
||||
(P.ppi_final_demand, "WPUFD4"),
|
||||
(P.ppi_finished_goods, "WPUFD4"), # back-compat alias
|
||||
(W.eci_wages, "CIU1020000000000A"),
|
||||
(W.eci_total_compensation, "CIU1010000000000A"),
|
||||
(W.ecec_total_benefits, "CMU1036000000000D"),
|
||||
(PR.business_output_per_hour, "PRS84006092"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fn,expected", GOLDEN, ids=[f.__name__ for f, _ in GOLDEN])
|
||||
def test_helper_exact_id(fn, expected):
|
||||
assert fn() == expected
|
||||
|
||||
|
||||
def test_software_developers_uses_2018_soc():
|
||||
# 15-1252 (2018 SOC); the old 15-1132 was retired and returns no data.
|
||||
assert W.SOC_CODES["software_developers"] == "151252"
|
||||
assert "151252" in W.occupation_annual_median_wage("151252")
|
||||
|
||||
|
||||
# --- Structural sweep: every zero-arg helper -> well-formed ID -----------------
|
||||
|
||||
# Surveys with a single fixed-width series ID — these are the ones whose
|
||||
# encodings broke before, so we enforce exact length. Other valid surveys
|
||||
# (CPI, PPI, average prices, import/export, CPS) have variable-width IDs and
|
||||
# are checked only for a well-formed prefix.
|
||||
FIXED_LEN = {
|
||||
"LA": 20, "CE": 13, "SM": 20, "OE": 25, "JT": 21,
|
||||
"CI": 17, "CM": 17, "PR": 11, "EN": 13,
|
||||
}
|
||||
KNOWN_PREFIXES = set(FIXED_LEN) | {"CU", "CW", "SU", "WP", "LN", "LE", "AP", "EI"}
|
||||
|
||||
|
||||
def _zero_arg_helpers():
|
||||
out = []
|
||||
for mod in (E, P, W, PR):
|
||||
for name, fn in inspect.getmembers(mod, inspect.isfunction):
|
||||
if fn.__module__ != mod.__name__:
|
||||
continue
|
||||
sig = inspect.signature(fn)
|
||||
if any(p.default is inspect._empty for p in sig.parameters.values()):
|
||||
continue
|
||||
out.append((f"{mod.__name__.split('.')[-1]}.{name}", fn))
|
||||
return out
|
||||
|
||||
|
||||
def _ids_from(fn):
|
||||
out = fn()
|
||||
if isinstance(out, str):
|
||||
return [out]
|
||||
if isinstance(out, dict):
|
||||
return [v for v in out.values() if isinstance(v, str)]
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,fn", _zero_arg_helpers(), ids=lambda v: v if isinstance(v, str) else "")
|
||||
def test_helper_ids_well_formed(label, fn):
|
||||
ids = _ids_from(fn)
|
||||
assert ids, f"{label} produced no series IDs"
|
||||
for sid in ids:
|
||||
prefix = sid[:2]
|
||||
assert sid.isalnum(), f"{label}: non-alphanumeric series ID {sid!r}"
|
||||
assert prefix.isupper() and prefix.isalpha(), f"{label}: bad prefix in {sid!r}"
|
||||
assert prefix in KNOWN_PREFIXES, f"{label}: unknown survey prefix in {sid!r}"
|
||||
if prefix in FIXED_LEN:
|
||||
assert len(sid) == FIXED_LEN[prefix], (
|
||||
f"{label}: {sid!r} is {len(sid)} chars, expected {FIXED_LEN[prefix]} for {prefix}"
|
||||
)
|
||||
88
tests/test_series_builders.py
Normal file
88
tests/test_series_builders.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""Unit tests for the low-level series-ID builders in bls_client.series.
|
||||
|
||||
These lock the exact encodings against known-good BLS series IDs. They run
|
||||
offline (no network) and are the regression guard for the encoding bugs that
|
||||
were fixed in June 2026 (JOLTS width, OES area code, ECI component/estimate,
|
||||
productivity sector/measure widths, QCEW form).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from bls_client import series as S
|
||||
|
||||
# (callable, kwargs) -> exact expected series ID. Every value here has been
|
||||
# confirmed to return data from the live BLS API.
|
||||
GOLDEN = [
|
||||
# LAUS — 20 chars
|
||||
(S.laus_state, dict(state_fips=11, measure="rate"), "LAUST110000000000003"),
|
||||
(S.laus_state, dict(state_fips=11, measure="rate", seasonal=True), "LASST110000000000003"),
|
||||
(S.laus_county, dict(state_fips=11, county_fips=1, measure="rate"), "LAUCN110010000000003"),
|
||||
(S.laus_msa, dict(state_fips=11, cbsa_code=47900, measure="rate"),"LAUMT114790000000003"),
|
||||
# CES national / state
|
||||
(S.ces_national, dict(), "CES0000000001"),
|
||||
(S.ces_national, dict(industry="manufacturing"), "CES3000000001"),
|
||||
(S.ces_state, dict(state_fips=11), "SMS11000000000000001"),
|
||||
# CPI / PPI
|
||||
(S.cpi, dict(), "CUUR0000SA0"),
|
||||
(S.cpi, dict(item="core", seasonal=True), "CUSR0000SA0L1E"),
|
||||
(S.ppi_commodity,dict(), "WPU00000000"),
|
||||
# OES — 25 chars, national area 0000000 (was the 0000400 bug)
|
||||
(S.oes_national, dict(), "OEUN000000000000000000001"),
|
||||
(S.oes_national, dict(occupation_code="151252", data_type="annual_median"),
|
||||
"OEUN000000000000015125213"),
|
||||
# JOLTS — 21 chars / 15 zeros (was 18 chars / 12 zeros)
|
||||
(S.jolts, dict(), "JTS000000000000000JOL"),
|
||||
(S.jolts, dict(element="quits", rate_level="R"), "JTS000000000000000QUR"),
|
||||
(S.jolts, dict(element="hires", seasonal=False), "JTU000000000000000HIL"),
|
||||
# ECI — 17 chars, unadjusted, component 10/20/30, estimate A
|
||||
(S.eci, dict(), "CIU1010000000000A"),
|
||||
(S.eci, dict(component="20"), "CIU1020000000000A"),
|
||||
(S.eci, dict(component="30"), "CIU1030000000000A"),
|
||||
(S.eci, dict(owner="20"), "CIU2010000000000A"),
|
||||
# Productivity — 11 chars, 4-digit sector + 4-digit measure
|
||||
(S.productivity, dict(), "PRS85006092"),
|
||||
(S.productivity, dict(sector="manufacturing", measure="unit_labor_cost"), "PRS30006112"),
|
||||
(S.productivity, dict(sector="business"), "PRS84006092"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fn,kwargs,expected", GOLDEN, ids=[f"{f.__name__}-{e}" for f, _, e in GOLDEN])
|
||||
def test_builder_exact_id(fn, kwargs, expected):
|
||||
assert fn(**kwargs) == expected
|
||||
|
||||
|
||||
# --- Format invariants (catch width regressions even for un-goldened inputs) ---
|
||||
|
||||
def test_jolts_is_21_chars():
|
||||
for elem in ("job_openings", "hires", "quits", "layoffs", "total_separations"):
|
||||
sid = S.jolts(elem)
|
||||
assert len(sid) == 21, f"{sid} should be 21 chars"
|
||||
assert sid.startswith("JTS")
|
||||
|
||||
|
||||
def test_oes_is_25_chars_national():
|
||||
sid = S.oes_national()
|
||||
assert len(sid) == 25
|
||||
assert sid.startswith("OEUN0000000"), "national area code must be 0000000"
|
||||
|
||||
|
||||
def test_eci_is_17_chars_and_unadjusted_by_default():
|
||||
sid = S.eci()
|
||||
assert len(sid) == 17
|
||||
assert sid.startswith("CIU"), "12-month % change ECI is published unadjusted (CIU)"
|
||||
|
||||
|
||||
def test_productivity_is_11_chars():
|
||||
sid = S.productivity()
|
||||
assert len(sid) == 11
|
||||
assert sid.startswith("PRS")
|
||||
|
||||
|
||||
def test_laus_state_is_20_chars():
|
||||
assert len(S.laus_state(48, "rate")) == 20
|
||||
|
||||
|
||||
def test_seasonal_flag_flips_adjustment_char():
|
||||
assert S.cpi(seasonal=False).startswith("CUU")
|
||||
assert S.cpi(seasonal=True).startswith("CUS")
|
||||
assert S.jolts(seasonal=False).startswith("JTU")
|
||||
assert S.jolts(seasonal=True).startswith("JTS")
|
||||
Reference in New Issue
Block a user