Skip to content

Commit d00cdc7

Browse files
authored
Merge pull request #292 from chekos/claude/tidycensus-evaluation-KVDPg
Add comprehensive implementation plan and test suite for tidycensus feature parity
2 parents 272e467 + f51f5d1 commit d00cdc7

16 files changed

Lines changed: 2104 additions & 1 deletion

TIDYCENSUS_EVALUATION.md

Lines changed: 822 additions & 0 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ module-root = ""
4444
norecursedirs = ["*.egg", ".eggs", "dist", "build", "docs", ".tox", ".git", "__pycache__"]
4545
doctest_optionflags = ["NUMBER", "NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"]
4646
addopts = "--strict-markers --tb=short --doctest-modules --doctest-continue-on-failure"
47+
markers = [
48+
"phase0: Foundation (API infra, geography, FIPS, cache)",
49+
"phase1: Core data functions (get_acs, get_decennial, load_variables)",
50+
"phase2: MOE, spatial, enhanced PUMS",
51+
"phase3: Estimates, flows, survey",
52+
"integration: Requires real Census API key and network",
53+
"spatial: Requires geopandas",
54+
]
4755

4856
[tool.ruff]
4957
target-version = "py310"

tests/conftest.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Shared fixtures for pypums test suite.
2+
3+
Provides mock Census API responses, fake API keys, and temporary
4+
cache directories used across all test files.
5+
"""
6+
7+
import pytest
8+
9+
10+
@pytest.fixture()
11+
def fake_api_key():
12+
"""A fake Census API key for testing."""
13+
return "fake_test_key_0123456789abcdef"
14+
15+
16+
@pytest.fixture()
17+
def cache_dir(tmp_path):
18+
"""Temporary cache directory for testing."""
19+
d = tmp_path / "cache"
20+
d.mkdir()
21+
return d
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Sample Census API JSON responses
26+
# These mirror the structure returned by api.census.gov so we can mock httpx
27+
# calls and still test DataFrame construction, column naming, etc.
28+
# ---------------------------------------------------------------------------
29+
30+
@pytest.fixture()
31+
def acs_api_response_tidy():
32+
"""Sample ACS API response (JSON rows) for 2 variables, 2 counties."""
33+
return [
34+
["NAME", "B01001_001E", "B01001_001M", "B02001_002E", "B02001_002M", "state", "county"],
35+
["Los Angeles County, California", "10014009", "0", "2786755", "9012", "06", "037"],
36+
["Orange County, California", "3186989", "0", "1298431", "6234", "06", "059"],
37+
]
38+
39+
40+
@pytest.fixture()
41+
def decennial_api_response():
42+
"""Sample Decennial Census API response for 2 variables, 2 states."""
43+
return [
44+
["NAME", "P1_001N", "P1_002N", "state"],
45+
["California", "39538223", "39538223", "06"],
46+
["Texas", "29145505", "29145505", "48"],
47+
]
48+
49+
50+
@pytest.fixture()
51+
def variables_api_response():
52+
"""Sample variables endpoint response."""
53+
return {
54+
"variables": {
55+
"B01001_001E": {
56+
"label": "Estimate!!Total:",
57+
"concept": "SEX BY AGE",
58+
"predicateType": "int",
59+
"group": "B01001",
60+
},
61+
"B01001_002E": {
62+
"label": "Estimate!!Total:!!Male:",
63+
"concept": "SEX BY AGE",
64+
"predicateType": "int",
65+
"group": "B01001",
66+
},
67+
}
68+
}
69+
70+
71+
@pytest.fixture()
72+
def pums_api_response():
73+
"""Sample PUMS API response with person-level records."""
74+
return [
75+
["SERIALNO", "SPORDER", "PWGTP", "AGEP", "SEX", "ST", "PUMA"],
76+
["2023HU0000001", "01", "50", "35", "1", "06", "03701"],
77+
["2023HU0000001", "02", "45", "33", "2", "06", "03701"],
78+
["2023HU0000002", "01", "60", "42", "1", "06", "03701"],
79+
]
80+
81+
82+
@pytest.fixture()
83+
def estimates_api_response():
84+
"""Sample Population Estimates API response."""
85+
return [
86+
["NAME", "POP_2023", "DENSITY_2023", "state"],
87+
["California", "38965193", "254.3", "06"],
88+
["Texas", "30503301", "117.3", "48"],
89+
]
90+
91+
92+
@pytest.fixture()
93+
def flows_api_response():
94+
"""Sample ACS Migration Flows API response."""
95+
return [
96+
[
97+
"FULL1_NAME", "FULL2_NAME",
98+
"MOVEDIN", "MOVEDIN_M",
99+
"MOVEDOUT", "MOVEDOUT_M",
100+
"MOVEDNET", "MOVEDNET_M",
101+
"state1", "county1", "state2", "county2",
102+
],
103+
[
104+
"Los Angeles County, California",
105+
"Maricopa County, Arizona",
106+
"15234", "1200", "22456", "1500", "-7222", "1921",
107+
"06", "037", "04", "013",
108+
],
109+
]

tests/test_api_key.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Tests for Census API key management.
2+
3+
Phase 0 — Foundation.
4+
5+
These tests define the contract for ``census_api_key()``:
6+
* Set a key and retrieve it back within the same session.
7+
* Read a key from the ``CENSUS_API_KEY`` environment variable.
8+
* Raise a clear error when no key is configured.
9+
"""
10+
11+
import os
12+
13+
import pytest
14+
15+
from pypums import census_api_key
16+
17+
pytestmark = pytest.mark.phase0
18+
19+
20+
def test_census_api_key_set_and_get(monkeypatch, fake_api_key):
21+
"""Setting a key stores it so a subsequent call retrieves it."""
22+
# Clear any pre-existing env var so we're testing in-memory storage.
23+
monkeypatch.delenv("CENSUS_API_KEY", raising=False)
24+
25+
census_api_key(fake_api_key)
26+
assert census_api_key() == fake_api_key
27+
28+
29+
def test_census_api_key_from_env(monkeypatch, fake_api_key):
30+
"""When no key is explicitly set, fall back to the env var."""
31+
monkeypatch.setenv("CENSUS_API_KEY", fake_api_key)
32+
33+
assert census_api_key() == fake_api_key
34+
35+
36+
def test_census_api_key_missing_raises(monkeypatch):
37+
"""When no key is available anywhere, raise a helpful error."""
38+
monkeypatch.delenv("CENSUS_API_KEY", raising=False)
39+
40+
with pytest.raises((ValueError, OSError)):
41+
census_api_key()

tests/test_cache.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Tests for the local caching layer.
2+
3+
Phase 0 — Foundation.
4+
5+
The cache stores API responses and variable tables locally so repeated
6+
queries don't hit the Census API. Tests here verify the round-trip
7+
store → retrieve contract, TTL expiration, and clearing.
8+
"""
9+
10+
import time
11+
12+
import pandas as pd
13+
import pytest
14+
15+
from pypums.cache import CensusCache
16+
17+
pytestmark = pytest.mark.phase0
18+
19+
20+
@pytest.fixture()
21+
def cache(cache_dir):
22+
"""A CensusCache instance backed by a temporary directory."""
23+
return CensusCache(cache_dir)
24+
25+
26+
def test_cache_stores_and_retrieves(cache):
27+
"""Storing a DataFrame under a key and retrieving it returns equal data."""
28+
df = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
29+
cache.set("test_key", df)
30+
result = cache.get("test_key")
31+
assert result is not None
32+
pd.testing.assert_frame_equal(result, df)
33+
34+
35+
def test_cache_miss_returns_none(cache):
36+
"""Getting a key that was never stored returns None."""
37+
assert cache.get("nonexistent_key") is None
38+
39+
40+
def test_cache_respects_ttl(cache):
41+
"""An entry older than its TTL should not be returned."""
42+
df = pd.DataFrame({"a": [1]})
43+
cache.set("short_lived", df, ttl_seconds=0)
44+
# Even a TTL of 0 means "already expired"
45+
time.sleep(0.05)
46+
assert cache.get("short_lived") is None
47+
48+
49+
def test_cache_clear(cache):
50+
"""Clearing the cache removes all stored entries."""
51+
df = pd.DataFrame({"a": [1]})
52+
cache.set("key1", df)
53+
cache.set("key2", df)
54+
cache.clear()
55+
assert cache.get("key1") is None
56+
assert cache.get("key2") is None

tests/test_fips.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Tests for the built-in FIPS codes dataset.
2+
3+
Phase 0 — Foundation.
4+
5+
The ``fips_codes`` dataset should be a pandas DataFrame bundling every
6+
US state and county FIPS code, mirroring the ``fips_codes`` tibble shipped
7+
with tidycensus.
8+
"""
9+
10+
import pandas as pd
11+
import pytest
12+
13+
from pypums.datasets import fips_codes
14+
15+
pytestmark = pytest.mark.phase0
16+
17+
18+
def test_fips_codes_is_dataframe():
19+
"""``fips_codes`` is a pandas DataFrame."""
20+
assert isinstance(fips_codes, pd.DataFrame)
21+
22+
23+
def test_fips_codes_has_expected_columns():
24+
"""Must include at minimum these identifier columns."""
25+
for col in ("state", "state_code", "county", "county_code"):
26+
assert col in fips_codes.columns, f"Missing column: {col}"
27+
28+
29+
def test_fips_codes_has_all_states():
30+
"""All 50 states + DC should be present."""
31+
states = fips_codes["state"].unique()
32+
assert len(states) >= 51 # 50 states + DC at minimum
33+
34+
35+
def test_fips_lookup_california():
36+
"""Can look up California by name and get FIPS code '06'."""
37+
ca = fips_codes[fips_codes["state"] == "California"]
38+
assert not ca.empty
39+
assert ca["state_code"].iloc[0] == "06"

tests/test_geography.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Tests for geography hierarchy definitions and validation.
2+
3+
Phase 0 — Foundation.
4+
5+
These tests define the contract for the geography module:
6+
* All 25+ Census geography names are accepted.
7+
* Geographies that require parent geographies (e.g. county → state) raise
8+
when those parents are not supplied.
9+
* ``build_geography_query`` returns the correct Census API ``for`` / ``in``
10+
parameter strings.
11+
"""
12+
13+
import pytest
14+
15+
from pypums.api.geography import GEOGRAPHY_HIERARCHY, build_geography_query
16+
17+
pytestmark = pytest.mark.phase0
18+
19+
20+
# The minimum set of geographies tidycensus supports — pypums must too.
21+
EXPECTED_GEOGRAPHIES = [
22+
"us",
23+
"region",
24+
"division",
25+
"state",
26+
"county",
27+
"county subdivision",
28+
"tract",
29+
"block group",
30+
"block",
31+
"place",
32+
"congressional district",
33+
"state legislative district (upper)",
34+
"state legislative district (lower)",
35+
"zcta",
36+
"school district (unified)",
37+
"school district (elementary)",
38+
"school district (secondary)",
39+
"cbsa",
40+
"csa",
41+
"puma",
42+
"american indian area/alaska native area/hawaiian home land",
43+
]
44+
45+
46+
@pytest.mark.parametrize("geo", EXPECTED_GEOGRAPHIES)
47+
def test_valid_geography_names(geo):
48+
"""Every expected geography string is present in the hierarchy mapping."""
49+
assert geo in GEOGRAPHY_HIERARCHY
50+
51+
52+
def test_county_requires_state():
53+
"""Requesting county-level data without a state should raise."""
54+
with pytest.raises(ValueError, match="(?i)state"):
55+
build_geography_query("county")
56+
57+
58+
def test_tract_requires_state_and_county():
59+
"""Requesting tract-level data without state+county should raise."""
60+
with pytest.raises(ValueError):
61+
build_geography_query("tract", state="06") # missing county
62+
63+
64+
def test_build_geography_query_state():
65+
"""State-level query needs no 'in' clause."""
66+
for_clause, in_clause = build_geography_query("state")
67+
assert "state" in for_clause
68+
assert in_clause is None
69+
70+
71+
def test_build_geography_query_county_in_state():
72+
"""County-level query returns a 'for' and an 'in' clause."""
73+
for_clause, in_clause = build_geography_query(
74+
"county", state="06"
75+
)
76+
assert "county" in for_clause
77+
assert in_clause is not None
78+
assert "state:06" in in_clause

0 commit comments

Comments
 (0)