"""bls-cpi-components-python-no-key.py — pull CPI components and the employment headline from the BLS public API v1 (no key) and compute year-over-year changes. What it does: POSTs a JSON request to https://api.bls.gov/publicAPI/v1/timeseries/data/ for up to 25 series and 10 years (the v1 limits stated on the BLS API pages), writes a tidy CSV (series_id, year, period, date, value), and prints the latest month with 12-month percent changes for the CPI series and the monthly change for payrolls. Series : CUSR0000SA0 (CPI-U all items, s.a.), CUSR0000SA0L1E (all items less food and energy), CUSR0000SAF1 (food), CUSR0000SA0E (energy), CUSR0000SAH1 (shelter), LNS14000000 (unemployment rate), CES0000000001 (total nonfarm payrolls, thousands). Cite each by series id and retrieval date. Run : python code/bls-cpi-components-python-no-key.py [--fetch] (without --fetch it reads the CSV) Outputs : datasets/bls-cpi-components-python-no-key.csv and a Markdown table. Needs : Python 3.13 standard library for the fetch; pandas 3.0.2 for the table. """ import datetime as dt import json import sys import urllib.request import pandas as pd CSV = "datasets/bls-cpi-components-python-no-key.csv" SERIES = ["CUSR0000SA0", "CUSR0000SA0L1E", "CUSR0000SAF1", "CUSR0000SA0E", "CUSR0000SAH1", "LNS14000000", "CES0000000001"] LABEL = {"CUSR0000SA0": "CPI-U all items", "CUSR0000SA0L1E": "CPI-U less food and energy", "CUSR0000SAF1": "food", "CUSR0000SA0E": "energy", "CUSR0000SAH1": "shelter", "LNS14000000": "unemployment rate (%)", "CES0000000001": "nonfarm payrolls (thousands)"} def fetch(start_year: int, end_year: int) -> pd.DataFrame: body = json.dumps({"seriesid": SERIES, "startyear": str(start_year), "endyear": str(end_year)}).encode() req = urllib.request.Request("https://api.bls.gov/publicAPI/v1/timeseries/data/", data=body, headers={"Content-type": "application/json", "User-Agent": "prism-data-lab/1.0"}) with urllib.request.urlopen(req, timeout=60) as r: j = json.loads(r.read().decode()) if j.get("status") != "REQUEST_SUCCEEDED": raise SystemExit(f"BLS API: {j.get('status')} {j.get('message')}") rows = [] for s in j["Results"]["series"]: for d in s["data"]: if d["period"].startswith("M") and d["period"] != "M13": try: val = float(d["value"]) except ValueError: # BLS marks a missing month with "-" (e.g. LNS14000000 for 2025-10) val = float("nan") rows.append({"series_id": s["seriesID"], "year": int(d["year"]), "period": d["period"], "date": f"{d['year']}-{d['period'][1:]}-01", "value": val}) df = pd.DataFrame(rows).sort_values(["series_id", "date"]).reset_index(drop=True) df["retrieved"] = dt.datetime.now(dt.timezone.utc).date().isoformat() return df def main(): if "--fetch" in sys.argv: y = dt.date.today().year fetch(y - 9, y).to_csv(CSV, index=False) df = pd.read_csv(CSV, parse_dates=["date"]) wide = df.pivot(index="date", columns="series_id", values="value").sort_index() # a complete monthly index, so a missing month (2025-10 in several series) stays a gap and shift(12) # is a shift of twelve calendar months, not twelve rows wide = wide.reindex(pd.date_range(wide.index[0], wide.index[-1], freq="MS")) gaps = {sid: [f"{d:%Y-%m}" for d in wide.index[wide[sid].isna() & (wide.index <= wide[sid].last_valid_index())]] for sid in SERIES} print(f"file covers {wide.index[0]:%Y-%m} to {wide.index[-1]:%Y-%m}; retrieved {df['retrieved'].iloc[0]}") print(f"months missing inside a series: { {k: v for k, v in gaps.items() if v} }") print("| series id | what | latest month | latest value | 12-month change | month-on-month change |") print("|---|---|---|---|---|---|") for sid in SERIES: s = wide[sid] last = s.last_valid_index() v, v12, v1 = s.loc[last], s.shift(12).loc[last], s.shift(1).loc[last] if sid == "LNS14000000": yoy_s, mom_s = f"{v - v12:+.1f} pp", f"{v - v1:+.1f} pp" elif sid == "CES0000000001": yoy_s, mom_s = f"{v - v12:+,.0f}k", f"{v - v1:+,.0f}k" else: yoy_s, mom_s = f"{100 * (v / v12 - 1):+.1f}%", f"{100 * (v / v1 - 1):+.2f}%" print(f"| {sid} | {LABEL[sid]} | {last:%Y-%m} | {v:,.3f} | {yoy_s} | {mom_s} |") cpi = wide["CUSR0000SA0"] yoy = (100 * (cpi / cpi.shift(12) - 1)).dropna() print(f"\nCPI-U all items 12-month change, last 13 months: " + ", ".join(f"{d:%Y-%m} {v:.1f}%" for d, v in yoy.tail(13).items())) print(f"peak 12-month change in file: {yoy.max():.1f}% in {yoy.idxmax():%Y-%m}") if __name__ == "__main__": main()