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
.gitignore
vendored
9
.gitignore
vendored
@ -2,3 +2,12 @@ config.py
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# packaging / test artifacts
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|||||||
14
README.md
14
README.md
@ -26,6 +26,8 @@ All 82 zero-argument query helpers are verified against the live API.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt # just `requests`
|
pip install -r requirements.txt # just `requests`
|
||||||
|
# or install the package itself (editable):
|
||||||
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configure your API key
|
## 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
|
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
|
## What's in the box
|
||||||
@ -100,10 +110,6 @@ for the series-ID decode tables.
|
|||||||
benefits totals are currently exposed.
|
benefits totals are currently exposed.
|
||||||
- **No caching / rate-limit handling.** Repeated runs spend against the 500/day quota; a
|
- **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.
|
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
31
pyproject.toml
Normal 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
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