Skip to content

Commit 58615e1

Browse files
chekosclaude
andauthored
Add Phase 0 foundation: Census API infrastructure (#293)
* Add Phase 0 foundation: Census API infrastructure Implement the foundation layer for Census API integration (tidycensus parity): - API key management via census_api_key() with env var support - Geography hierarchy (21 types) with validation and query building - File-based CensusCache with TTL support - Bundled FIPS codes dataset (3,234 state/county entries) - Spatial optional dependency group in pyproject.toml All 36 phase0 TDD tests now pass. Existing tests unaffected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR review feedback - Remove unused install/overwrite params from census_api_key() to avoid silently ignoring caller intent - Hash cache keys with SHA-256 to prevent path traversal - Scope CensusCache.clear() to only remove .pkl and .meta.json files - Fix geography in-clause separator from "+" to space (Census API format) - Add public exports to pypums/api/__init__.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9225245 commit 58615e1

10 files changed

Lines changed: 3761 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Changelog = "https://github.com/chekos/pypums/releases"
2828
pypums = "pypums.cli:cli"
2929

3030
[project.optional-dependencies]
31+
spatial = ["geopandas>=0.12"]
3132
test = ["pytest"]
3233
docs = [
3334
"mkdocs",

pypums/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# type: ignore[attr-defined]
22
"""Download PUMS data files from the US Census Bureau's FTP server."""
33

4+
from pypums.api.key import census_api_key as census_api_key
45
from pypums.surveys import ACS as ACS
56

67
from .constants import __app_name__ as __app_name__

pypums/api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Census API integration."""
2+
3+
from pypums.api.geography import GEOGRAPHY_HIERARCHY as GEOGRAPHY_HIERARCHY
4+
from pypums.api.geography import build_geography_query as build_geography_query
5+
from pypums.api.key import census_api_key as census_api_key

pypums/api/geography.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Census geography hierarchy definitions and query building."""
2+
3+
GEOGRAPHY_HIERARCHY: dict[str, dict] = {
4+
"us": {"for": "us:1", "requires": []},
5+
"region": {"for": "region:*", "requires": []},
6+
"division": {"for": "division:*", "requires": []},
7+
"state": {"for": "state:*", "requires": []},
8+
"county": {"for": "county:*", "requires": ["state"]},
9+
"county subdivision": {
10+
"for": "county subdivision:*",
11+
"requires": ["state", "county"],
12+
},
13+
"tract": {"for": "tract:*", "requires": ["state", "county"]},
14+
"block group": {
15+
"for": "block group:*",
16+
"requires": ["state", "county"],
17+
},
18+
"block": {"for": "block:*", "requires": ["state", "county"]},
19+
"place": {"for": "place:*", "requires": ["state"]},
20+
"congressional district": {
21+
"for": "congressional district:*",
22+
"requires": ["state"],
23+
},
24+
"state legislative district (upper)": {
25+
"for": "state legislative district (upper chamber):*",
26+
"requires": ["state"],
27+
},
28+
"state legislative district (lower)": {
29+
"for": "state legislative district (lower chamber):*",
30+
"requires": ["state"],
31+
},
32+
"zcta": {"for": "zip code tabulation area:*", "requires": []},
33+
"school district (unified)": {
34+
"for": "school district (unified):*",
35+
"requires": ["state"],
36+
},
37+
"school district (elementary)": {
38+
"for": "school district (elementary):*",
39+
"requires": ["state"],
40+
},
41+
"school district (secondary)": {
42+
"for": "school district (secondary):*",
43+
"requires": ["state"],
44+
},
45+
"cbsa": {
46+
"for": "metropolitan statistical area/micropolitan statistical area:*",
47+
"requires": [],
48+
},
49+
"csa": {"for": "combined statistical area:*", "requires": []},
50+
"puma": {"for": "public use microdata area:*", "requires": ["state"]},
51+
"american indian area/alaska native area/hawaiian home land": {
52+
"for": "american indian area/alaska native area/hawaiian home land:*",
53+
"requires": [],
54+
},
55+
}
56+
57+
58+
def build_geography_query(
59+
geography: str,
60+
state: str | None = None,
61+
county: str | None = None,
62+
) -> tuple[str, str | None]:
63+
"""Convert geography + state/county to Census API ``for`` and ``in`` params.
64+
65+
Parameters
66+
----------
67+
geography
68+
Geography level name (e.g. ``"state"``, ``"county"``, ``"tract"``).
69+
state
70+
State FIPS code (e.g. ``"06"`` for California).
71+
county
72+
County FIPS code (e.g. ``"037"`` for Los Angeles County).
73+
74+
Returns
75+
-------
76+
tuple[str, str | None]
77+
A ``(for_clause, in_clause)`` pair for the Census API query.
78+
79+
Raises
80+
------
81+
ValueError
82+
If the geography is unknown or required parent geographies are missing.
83+
"""
84+
geo = geography.lower()
85+
if geo not in GEOGRAPHY_HIERARCHY:
86+
raise ValueError(
87+
f"Unknown geography: {geography!r}. "
88+
f"Valid options: {sorted(GEOGRAPHY_HIERARCHY)}"
89+
)
90+
91+
spec = GEOGRAPHY_HIERARCHY[geo]
92+
required = spec["requires"]
93+
94+
if "state" in required and state is None:
95+
raise ValueError(
96+
f"Geography {geography!r} requires a state FIPS code. "
97+
"Pass state='XX' (e.g. state='06' for California)."
98+
)
99+
if "county" in required and county is None:
100+
raise ValueError(
101+
f"Geography {geography!r} requires a county FIPS code. "
102+
"Pass county='XXX' (e.g. county='037' for Los Angeles County)."
103+
)
104+
105+
for_clause = spec["for"]
106+
107+
# Build the "in" clause from required parents
108+
in_parts = []
109+
if "state" in required and state is not None:
110+
in_parts.append(f"state:{state}")
111+
if "county" in required and county is not None:
112+
in_parts.append(f"county:{county}")
113+
114+
in_clause = " ".join(in_parts) if in_parts else None
115+
116+
return for_clause, in_clause

pypums/api/key.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Census API key management."""
2+
3+
import os
4+
5+
6+
def census_api_key(key: str | None = None) -> str:
7+
"""Get or set a Census API key for the current session.
8+
9+
Parameters
10+
----------
11+
key
12+
If provided, sets this key for the current session via env var.
13+
14+
Returns
15+
-------
16+
str
17+
The active Census API key.
18+
19+
Raises
20+
------
21+
ValueError
22+
If no key is available from any source.
23+
"""
24+
if key is not None:
25+
os.environ["CENSUS_API_KEY"] = key
26+
return key
27+
28+
env_key = os.environ.get("CENSUS_API_KEY")
29+
if env_key is not None:
30+
return env_key
31+
32+
raise ValueError(
33+
"No Census API key found. Set one with census_api_key('your-key') "
34+
"or set the CENSUS_API_KEY environment variable. "
35+
"Request a key at https://api.census.gov/data/key_signup.html"
36+
)

pypums/cache.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""File-based caching for Census API responses and variable tables."""
2+
3+
import hashlib
4+
import json
5+
import pickle
6+
import time
7+
from pathlib import Path
8+
9+
import pandas as pd
10+
11+
_CACHE_DATA_SUFFIX = ".pkl"
12+
_CACHE_META_SUFFIX = ".meta.json"
13+
14+
15+
class CensusCache:
16+
"""Cache Census API responses with optional TTL.
17+
18+
Parameters
19+
----------
20+
cache_dir
21+
Directory to store cached files.
22+
"""
23+
24+
def __init__(self, cache_dir: Path) -> None:
25+
self._dir = Path(cache_dir)
26+
self._dir.mkdir(parents=True, exist_ok=True)
27+
28+
@staticmethod
29+
def _safe_name(key: str) -> str:
30+
"""Hash the key to produce a safe, fixed-length filename."""
31+
return hashlib.sha256(key.encode()).hexdigest()
32+
33+
def _data_path(self, key: str) -> Path:
34+
return self._dir / f"{self._safe_name(key)}{_CACHE_DATA_SUFFIX}"
35+
36+
def _meta_path(self, key: str) -> Path:
37+
return self._dir / f"{self._safe_name(key)}{_CACHE_META_SUFFIX}"
38+
39+
def set(
40+
self,
41+
key: str,
42+
df: pd.DataFrame,
43+
ttl_seconds: int | float | None = None,
44+
) -> None:
45+
"""Store a DataFrame in the cache.
46+
47+
Parameters
48+
----------
49+
key
50+
Cache key identifier.
51+
df
52+
DataFrame to cache.
53+
ttl_seconds
54+
Time-to-live in seconds. ``None`` means no expiration.
55+
"""
56+
with open(self._data_path(key), "wb") as f:
57+
pickle.dump(df, f)
58+
meta = {"created_at": time.time(), "ttl_seconds": ttl_seconds}
59+
self._meta_path(key).write_text(json.dumps(meta))
60+
61+
def get(self, key: str) -> pd.DataFrame | None:
62+
"""Retrieve a cached DataFrame, or ``None`` if missing/expired."""
63+
data_path = self._data_path(key)
64+
meta_path = self._meta_path(key)
65+
66+
if not data_path.exists() or not meta_path.exists():
67+
return None
68+
69+
meta = json.loads(meta_path.read_text())
70+
ttl = meta.get("ttl_seconds")
71+
72+
if ttl is not None:
73+
age = time.time() - meta["created_at"]
74+
if age > ttl:
75+
data_path.unlink(missing_ok=True)
76+
meta_path.unlink(missing_ok=True)
77+
return None
78+
79+
with open(data_path, "rb") as f:
80+
return pickle.load(f) # noqa: S301
81+
82+
def clear(self) -> None:
83+
"""Remove all cached entries."""
84+
for path in self._dir.glob(f"*{_CACHE_DATA_SUFFIX}"):
85+
path.unlink()
86+
for path in self._dir.glob(f"*{_CACHE_META_SUFFIX}"):
87+
path.unlink()

pypums/datasets/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Built-in reference datasets."""
2+
3+
from pypums.datasets.fips import fips_codes as fips_codes

0 commit comments

Comments
 (0)