Add BLS client library, example scripts, and usage docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
171
bls_client/client.py
Normal file
171
bls_client/client.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""
|
||||
BLSClient — thin wrapper around the BLS Public Data API v2.
|
||||
|
||||
Usage:
|
||||
from bls_client import BLSClient
|
||||
client = BLSClient("YOUR_API_KEY")
|
||||
data = client.fetch(["CES0000000001"], 2020, 2025)
|
||||
"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BLSClient:
|
||||
BASE_URL = "https://api.bls.gov/publicAPI/v2"
|
||||
MAX_SERIES_PER_CALL = 50
|
||||
MAX_YEARS_PER_CALL = 20
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
self.api_key = api_key
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core fetch
|
||||
# ------------------------------------------------------------------
|
||||
def fetch(
|
||||
self,
|
||||
series_ids: list[str] | str,
|
||||
start_year: int,
|
||||
end_year: int,
|
||||
catalog: bool = True,
|
||||
calculations: bool = False,
|
||||
annual_average: bool = False,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Fetch time-series data for one or more series IDs.
|
||||
|
||||
Automatically batches requests when series_ids > 50.
|
||||
Returns a dict keyed by series ID, value is the list of
|
||||
observations (newest first).
|
||||
|
||||
Args:
|
||||
series_ids: Single series ID or list of series IDs.
|
||||
start_year: First year to retrieve.
|
||||
end_year: Last year to retrieve (max 20 years from start).
|
||||
catalog: Include series title/metadata in response.
|
||||
calculations: Include MoM / YoY net and pct changes.
|
||||
annual_average: Include M13 annual average rows.
|
||||
|
||||
Returns:
|
||||
{series_id: [{"year":..., "period":..., "value":..., ...}, ...]}
|
||||
"""
|
||||
if isinstance(series_ids, str):
|
||||
series_ids = [series_ids]
|
||||
|
||||
results = {}
|
||||
for i in range(0, len(series_ids), self.MAX_SERIES_PER_CALL):
|
||||
batch = series_ids[i : i + self.MAX_SERIES_PER_CALL]
|
||||
payload = {
|
||||
"seriesid": batch,
|
||||
"startyear": str(start_year),
|
||||
"endyear": str(end_year),
|
||||
"registrationkey": self.api_key,
|
||||
"catalog": catalog,
|
||||
"calculations": calculations,
|
||||
"annualaverage": annual_average,
|
||||
}
|
||||
r = requests.post(
|
||||
f"{self.BASE_URL}/timeseries/data/",
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if body["status"] != "REQUEST_SUCCEEDED":
|
||||
raise RuntimeError(f"BLS API error: {body['message']}")
|
||||
for s in body["Results"]["series"]:
|
||||
results[s["seriesID"]] = {
|
||||
"data": s["data"],
|
||||
"catalog": s.get("catalog", {}),
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def fetch_latest(
|
||||
self,
|
||||
series_ids: list[str] | str,
|
||||
years: int = 2,
|
||||
**kwargs,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Convenience: fetch the most recent `years` years."""
|
||||
end = datetime.now().year
|
||||
start = end - (years - 1)
|
||||
return self.fetch(series_ids, start, end, **kwargs)
|
||||
|
||||
def fetch_named(
|
||||
self,
|
||||
named: dict[str, str],
|
||||
start_year: int,
|
||||
end_year: int,
|
||||
**kwargs,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Fetch a labeled dict of series IDs.
|
||||
|
||||
Args:
|
||||
named: {"Human label": "SERIES_ID", ...}
|
||||
Returns:
|
||||
{"Human label": {"data": [...], "catalog": {...}}}
|
||||
"""
|
||||
id_to_label = {v: k for k, v in named.items()}
|
||||
raw = self.fetch(list(named.values()), start_year, end_year, **kwargs)
|
||||
return {id_to_label[sid]: v for sid, v in raw.items()}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery endpoints
|
||||
# ------------------------------------------------------------------
|
||||
def surveys(self) -> list[dict]:
|
||||
"""Return all BLS surveys with abbreviation and name."""
|
||||
r = requests.get(f"{self.BASE_URL}/surveys", timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()["Results"]["survey"]
|
||||
|
||||
def popular(self, survey: str) -> list[str]:
|
||||
"""Return popular series IDs for a given survey abbreviation."""
|
||||
r = requests.get(
|
||||
f"{self.BASE_URL}/timeseries/popular",
|
||||
params={"survey": survey},
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
return [
|
||||
s["seriesID"]
|
||||
for s in body.get("Results", {}).get("series", [])
|
||||
if s
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def latest_obs(series_result: dict) -> dict | None:
|
||||
"""Return the most recent non-null observation from a fetch result."""
|
||||
for obs in series_result.get("data", []):
|
||||
if obs["value"] != "-":
|
||||
return obs
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def to_rows(results: dict[str, dict]) -> list[dict]:
|
||||
"""
|
||||
Flatten fetch results into a list of dicts suitable for CSV or
|
||||
pandas DataFrame ingestion.
|
||||
|
||||
Each row: {series_id, series_title, year, period, period_name, value}
|
||||
"""
|
||||
rows = []
|
||||
for sid, s in results.items():
|
||||
title = s.get("catalog", {}).get("series_title", "")
|
||||
for obs in s.get("data", []):
|
||||
if obs["value"] == "-":
|
||||
continue
|
||||
rows.append({
|
||||
"series_id": sid,
|
||||
"series_title": title,
|
||||
"year": obs["year"],
|
||||
"period": obs["period"],
|
||||
"period_name": obs.get("periodName", ""),
|
||||
"value": obs["value"],
|
||||
})
|
||||
return rows
|
||||
Reference in New Issue
Block a user