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:
2026-06-22 09:53:37 -04:00
parent 4fe734381e
commit 881d62cf32
7 changed files with 305 additions and 4 deletions

9
.gitignore vendored
View File

@ -2,3 +2,12 @@ config.py
__pycache__/
*.pyc
.env
# packaging / test artifacts
build/
dist/
*.egg-info/
.eggs/
.pytest_cache/
.venv/
venv/

View File

@ -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.
---

31
pyproject.toml Normal file
View File

@ -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'"

9
tests/conftest.py Normal file
View 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
View 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

View 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}"
)

View 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")