diff --git a/.gitignore b/.gitignore index 0a054b9..79f2fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,12 @@ config.py __pycache__/ *.pyc .env + +# packaging / test artifacts +build/ +dist/ +*.egg-info/ +.eggs/ +.pytest_cache/ +.venv/ +venv/ diff --git a/README.md b/README.md index eb2c004..53c770c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ All 82 zero-argument query helpers are verified against the live API. ```bash pip install -r requirements.txt # just `requests` +# or install the package itself (editable): +pip install -e . ``` ## Configure your API key @@ -48,6 +50,14 @@ python3 examples/basic_pull.py # payrolls, CPI dashboard, DC-region unem python3 examples/custom_series.py # building custom series IDs ``` +## Tests + +```bash +pip install -e ".[dev]" # pytest +pytest # offline: 95 tests locking the series-ID encodings +pytest -m live # also hit the live API (needs network + BLS_API_KEY) +``` + --- ## What's in the box @@ -100,10 +110,6 @@ for the series-ID decode tables. benefits totals are currently exposed. - **No caching / rate-limit handling.** Repeated runs spend against the 500/day quota; a small on-disk cache and a friendly error on `REQUEST_NOT_PROCESSED` (quota hit) would help. -- **No packaging.** Importable in-tree but not `pip install`-able; add a `pyproject.toml` - to ship it as a real package. -- **No automated tests.** A pytest suite asserting builder outputs against known-good IDs - (and a live smoke test behind a marker) would lock the series-ID encodings in place. --- diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9cbe72f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "bls-data" +version = "0.1.0" +description = "Dependency-light Python client for the U.S. Bureau of Labor Statistics (BLS) API, with pre-built series-ID builders." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "Dave Boyd", email = "dave@tiggr.org" }] +keywords = ["bls", "economics", "labor-statistics", "cpi", "jolts", "unemployment", "wages"] +dependencies = ["requests>=2.28"] + +[project.optional-dependencies] +dev = ["pytest>=7"] + +[project.urls] +Homepage = "https://gitea.doesworks.net/giteaadmin/bls-data" + +[tool.setuptools] +packages = ["bls_client", "bls_client.queries"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "live: hits the live BLS API (needs network + BLS_API_KEY; deselected by default)", +] +# Live tests are opt-in: run `pytest -m live` to include them. +addopts = "-m 'not live'" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6001e69 --- /dev/null +++ b/tests/conftest.py @@ -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__))) diff --git a/tests/test_live_smoke.py b/tests/test_live_smoke.py new file mode 100644 index 0000000..237cacd --- /dev/null +++ b/tests/test_live_smoke.py @@ -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 diff --git a/tests/test_query_helpers.py b/tests/test_query_helpers.py new file mode 100644 index 0000000..a995b36 --- /dev/null +++ b/tests/test_query_helpers.py @@ -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}" + ) diff --git a/tests/test_series_builders.py b/tests/test_series_builders.py new file mode 100644 index 0000000..4b0d596 --- /dev/null +++ b/tests/test_series_builders.py @@ -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")