nmtc-mapper 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/PKG-INFO +43 -2
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/README.md +40 -1
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/PKG-INFO +43 -2
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/SOURCES.txt +5 -3
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/requires.txt +3 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/top_level.txt +0 -1
- nmtc_mapper-0.3.4/nmtcmapper/__init__.py +24 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/data/loader.py +105 -20
- nmtc_mapper-0.3.4/nmtcmapper/exceptions.py +66 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/mapper.py +33 -2
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/pyproject.toml +11 -1
- nmtc_mapper-0.3.4/setup.py +6 -0
- nmtc_mapper-0.3.4/tests/test_exceptions.py +47 -0
- nmtc_mapper-0.3.4/tests/test_fail_loud.py +189 -0
- nmtc_mapper-0.3.4/tests/test_sample_mode.py +56 -0
- nmtc_mapper-0.3.2/nmtcmapper/__init__.py +0 -10
- nmtc_mapper-0.3.2/setup.py +0 -15
- nmtc_mapper-0.3.2/tests/__init__.py +0 -0
- nmtc_mapper-0.3.2/tests/conftest.py +0 -37
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/dependency_links.txt +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/data/__init__.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/data/schema.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/eligibility/__init__.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/eligibility/checker.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/geocoder/__init__.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/geocoder/census.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/setup.cfg +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/tests/test_checker.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/tests/test_loader.py +0 -0
- {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/tests/test_mapper.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nmtc-mapper
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Automated NMTC eligibility checker — geocode addresses and check Low-Income Community status using CDFI Fund and Census data
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Jaypatel1511/nmtc-mapper
|
|
@@ -13,6 +13,8 @@ Requires-Dist: numpy>=1.21.0
|
|
|
13
13
|
Requires-Dist: requests>=2.27.0
|
|
14
14
|
Requires-Dist: openpyxl>=3.1.0
|
|
15
15
|
Requires-Dist: pyxlsb>=1.0.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
16
18
|
|
|
17
19
|
# nmtc-mapper 🗺️
|
|
18
20
|
|
|
@@ -70,6 +72,44 @@ get results in seconds, using the same official data source.
|
|
|
70
72
|
|
|
71
73
|
---
|
|
72
74
|
|
|
75
|
+
## Failure behavior & offline / demo mode
|
|
76
|
+
|
|
77
|
+
`NMTCMapper()` downloads the official CDFI Fund eligibility and Opportunity Zone
|
|
78
|
+
files (cached under `~/.nmtcmapper/cache`). As of **0.3.4** it **fails loud**: if
|
|
79
|
+
a download or parse fails, it raises a typed error instead of silently
|
|
80
|
+
substituting demo data. (Before 0.3.4 any failure silently fell back to a
|
|
81
|
+
12-tract synthetic sample, which could report a real, eligible tract as
|
|
82
|
+
"ineligible" — see the CHANGELOG.)
|
|
83
|
+
|
|
84
|
+
from nmtcmapper import NMTCMapper, NMTCMapperError
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
mapper = NMTCMapper()
|
|
88
|
+
except NMTCMapperError as e:
|
|
89
|
+
# Blocked network, moved URL, corrupt file, etc. — never a fabricated answer.
|
|
90
|
+
print(f"Could not load real NMTC data: {e}")
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
The exception hierarchy (`NMTCMapperError` → `EligibilityDataError` /
|
|
94
|
+
`OZDataError` → specific `*DownloadError` / `*ParseError` leaves) is exported
|
|
95
|
+
from the top level, so you can catch broadly or precisely.
|
|
96
|
+
|
|
97
|
+
**Explicit demo / offline data** — for examples, tests, or an air-gapped demo,
|
|
98
|
+
opt in to the synthetic sample dataset. This performs **no network calls** and
|
|
99
|
+
stamps the mapper so you can tell demo answers from real ones:
|
|
100
|
+
|
|
101
|
+
from nmtcmapper import NMTCMapper, load_sample_table
|
|
102
|
+
|
|
103
|
+
mapper = NMTCMapper.from_sample() # 12 sample tracts + 6 OZ tracts, offline
|
|
104
|
+
print(mapper.data_source) # "sample" (real data → "cdfi_fund")
|
|
105
|
+
|
|
106
|
+
df = load_sample_table() # the raw 12-tract sample frame
|
|
107
|
+
|
|
108
|
+
> ⚠️ Sample data is 12 synthetic-vintage tracts for demos and tests. It is
|
|
109
|
+
> **never** valid for a real NMTC eligibility answer.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
73
113
|
## Eligibility Rules (2016-2020 ACS — mandatory since Sept 1, 2024)
|
|
74
114
|
|
|
75
115
|
A census tract qualifies as a Low-Income Community (LIC) if it meets ANY of:
|
|
@@ -115,7 +155,8 @@ After running .enrich(), your DataFrame will have:
|
|
|
115
155
|
|
|
116
156
|
PYTHONPATH=. pytest tests/ -v
|
|
117
157
|
|
|
118
|
-
|
|
158
|
+
44 tests across all modules (including fail-loud and explicit-sample-mode
|
|
159
|
+
coverage added in 0.3.4).
|
|
119
160
|
|
|
120
161
|
---
|
|
121
162
|
|
|
@@ -54,6 +54,44 @@ get results in seconds, using the same official data source.
|
|
|
54
54
|
|
|
55
55
|
---
|
|
56
56
|
|
|
57
|
+
## Failure behavior & offline / demo mode
|
|
58
|
+
|
|
59
|
+
`NMTCMapper()` downloads the official CDFI Fund eligibility and Opportunity Zone
|
|
60
|
+
files (cached under `~/.nmtcmapper/cache`). As of **0.3.4** it **fails loud**: if
|
|
61
|
+
a download or parse fails, it raises a typed error instead of silently
|
|
62
|
+
substituting demo data. (Before 0.3.4 any failure silently fell back to a
|
|
63
|
+
12-tract synthetic sample, which could report a real, eligible tract as
|
|
64
|
+
"ineligible" — see the CHANGELOG.)
|
|
65
|
+
|
|
66
|
+
from nmtcmapper import NMTCMapper, NMTCMapperError
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
mapper = NMTCMapper()
|
|
70
|
+
except NMTCMapperError as e:
|
|
71
|
+
# Blocked network, moved URL, corrupt file, etc. — never a fabricated answer.
|
|
72
|
+
print(f"Could not load real NMTC data: {e}")
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
The exception hierarchy (`NMTCMapperError` → `EligibilityDataError` /
|
|
76
|
+
`OZDataError` → specific `*DownloadError` / `*ParseError` leaves) is exported
|
|
77
|
+
from the top level, so you can catch broadly or precisely.
|
|
78
|
+
|
|
79
|
+
**Explicit demo / offline data** — for examples, tests, or an air-gapped demo,
|
|
80
|
+
opt in to the synthetic sample dataset. This performs **no network calls** and
|
|
81
|
+
stamps the mapper so you can tell demo answers from real ones:
|
|
82
|
+
|
|
83
|
+
from nmtcmapper import NMTCMapper, load_sample_table
|
|
84
|
+
|
|
85
|
+
mapper = NMTCMapper.from_sample() # 12 sample tracts + 6 OZ tracts, offline
|
|
86
|
+
print(mapper.data_source) # "sample" (real data → "cdfi_fund")
|
|
87
|
+
|
|
88
|
+
df = load_sample_table() # the raw 12-tract sample frame
|
|
89
|
+
|
|
90
|
+
> ⚠️ Sample data is 12 synthetic-vintage tracts for demos and tests. It is
|
|
91
|
+
> **never** valid for a real NMTC eligibility answer.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
57
95
|
## Eligibility Rules (2016-2020 ACS — mandatory since Sept 1, 2024)
|
|
58
96
|
|
|
59
97
|
A census tract qualifies as a Low-Income Community (LIC) if it meets ANY of:
|
|
@@ -99,7 +137,8 @@ After running .enrich(), your DataFrame will have:
|
|
|
99
137
|
|
|
100
138
|
PYTHONPATH=. pytest tests/ -v
|
|
101
139
|
|
|
102
|
-
|
|
140
|
+
44 tests across all modules (including fail-loud and explicit-sample-mode
|
|
141
|
+
coverage added in 0.3.4).
|
|
103
142
|
|
|
104
143
|
---
|
|
105
144
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nmtc-mapper
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Automated NMTC eligibility checker — geocode addresses and check Low-Income Community status using CDFI Fund and Census data
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Jaypatel1511/nmtc-mapper
|
|
@@ -13,6 +13,8 @@ Requires-Dist: numpy>=1.21.0
|
|
|
13
13
|
Requires-Dist: requests>=2.27.0
|
|
14
14
|
Requires-Dist: openpyxl>=3.1.0
|
|
15
15
|
Requires-Dist: pyxlsb>=1.0.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
16
18
|
|
|
17
19
|
# nmtc-mapper 🗺️
|
|
18
20
|
|
|
@@ -70,6 +72,44 @@ get results in seconds, using the same official data source.
|
|
|
70
72
|
|
|
71
73
|
---
|
|
72
74
|
|
|
75
|
+
## Failure behavior & offline / demo mode
|
|
76
|
+
|
|
77
|
+
`NMTCMapper()` downloads the official CDFI Fund eligibility and Opportunity Zone
|
|
78
|
+
files (cached under `~/.nmtcmapper/cache`). As of **0.3.4** it **fails loud**: if
|
|
79
|
+
a download or parse fails, it raises a typed error instead of silently
|
|
80
|
+
substituting demo data. (Before 0.3.4 any failure silently fell back to a
|
|
81
|
+
12-tract synthetic sample, which could report a real, eligible tract as
|
|
82
|
+
"ineligible" — see the CHANGELOG.)
|
|
83
|
+
|
|
84
|
+
from nmtcmapper import NMTCMapper, NMTCMapperError
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
mapper = NMTCMapper()
|
|
88
|
+
except NMTCMapperError as e:
|
|
89
|
+
# Blocked network, moved URL, corrupt file, etc. — never a fabricated answer.
|
|
90
|
+
print(f"Could not load real NMTC data: {e}")
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
The exception hierarchy (`NMTCMapperError` → `EligibilityDataError` /
|
|
94
|
+
`OZDataError` → specific `*DownloadError` / `*ParseError` leaves) is exported
|
|
95
|
+
from the top level, so you can catch broadly or precisely.
|
|
96
|
+
|
|
97
|
+
**Explicit demo / offline data** — for examples, tests, or an air-gapped demo,
|
|
98
|
+
opt in to the synthetic sample dataset. This performs **no network calls** and
|
|
99
|
+
stamps the mapper so you can tell demo answers from real ones:
|
|
100
|
+
|
|
101
|
+
from nmtcmapper import NMTCMapper, load_sample_table
|
|
102
|
+
|
|
103
|
+
mapper = NMTCMapper.from_sample() # 12 sample tracts + 6 OZ tracts, offline
|
|
104
|
+
print(mapper.data_source) # "sample" (real data → "cdfi_fund")
|
|
105
|
+
|
|
106
|
+
df = load_sample_table() # the raw 12-tract sample frame
|
|
107
|
+
|
|
108
|
+
> ⚠️ Sample data is 12 synthetic-vintage tracts for demos and tests. It is
|
|
109
|
+
> **never** valid for a real NMTC eligibility answer.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
73
113
|
## Eligibility Rules (2016-2020 ACS — mandatory since Sept 1, 2024)
|
|
74
114
|
|
|
75
115
|
A census tract qualifies as a Low-Income Community (LIC) if it meets ANY of:
|
|
@@ -115,7 +155,8 @@ After running .enrich(), your DataFrame will have:
|
|
|
115
155
|
|
|
116
156
|
PYTHONPATH=. pytest tests/ -v
|
|
117
157
|
|
|
118
|
-
|
|
158
|
+
44 tests across all modules (including fail-loud and explicit-sample-mode
|
|
159
|
+
coverage added in 0.3.4).
|
|
119
160
|
|
|
120
161
|
---
|
|
121
162
|
|
|
@@ -7,6 +7,7 @@ nmtc_mapper.egg-info/dependency_links.txt
|
|
|
7
7
|
nmtc_mapper.egg-info/requires.txt
|
|
8
8
|
nmtc_mapper.egg-info/top_level.txt
|
|
9
9
|
nmtcmapper/__init__.py
|
|
10
|
+
nmtcmapper/exceptions.py
|
|
10
11
|
nmtcmapper/mapper.py
|
|
11
12
|
nmtcmapper/data/__init__.py
|
|
12
13
|
nmtcmapper/data/loader.py
|
|
@@ -15,8 +16,9 @@ nmtcmapper/eligibility/__init__.py
|
|
|
15
16
|
nmtcmapper/eligibility/checker.py
|
|
16
17
|
nmtcmapper/geocoder/__init__.py
|
|
17
18
|
nmtcmapper/geocoder/census.py
|
|
18
|
-
tests/__init__.py
|
|
19
|
-
tests/conftest.py
|
|
20
19
|
tests/test_checker.py
|
|
20
|
+
tests/test_exceptions.py
|
|
21
|
+
tests/test_fail_loud.py
|
|
21
22
|
tests/test_loader.py
|
|
22
|
-
tests/test_mapper.py
|
|
23
|
+
tests/test_mapper.py
|
|
24
|
+
tests/test_sample_mode.py
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from nmtcmapper.mapper import NMTCMapper
|
|
2
|
+
from nmtcmapper.eligibility.checker import EligibilityResult
|
|
3
|
+
from nmtcmapper.data.loader import load_eligibility_table, load_sample_table
|
|
4
|
+
from nmtcmapper.geocoder.census import geocode_address
|
|
5
|
+
from nmtcmapper.exceptions import (
|
|
6
|
+
NMTCMapperError,
|
|
7
|
+
EligibilityDataError, EligibilityDownloadError, EligibilityParseError,
|
|
8
|
+
OZDataError, OZDownloadError, OZParseError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("nmtc-mapper")
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
__version__ = "0.0.0+unknown"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"NMTCMapper", "EligibilityResult",
|
|
20
|
+
"load_eligibility_table", "load_sample_table", "geocode_address",
|
|
21
|
+
"NMTCMapperError",
|
|
22
|
+
"EligibilityDataError", "EligibilityDownloadError", "EligibilityParseError",
|
|
23
|
+
"OZDataError", "OZDownloadError", "OZParseError",
|
|
24
|
+
]
|
|
@@ -7,6 +7,10 @@ import requests
|
|
|
7
7
|
import pandas as pd
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
from nmtcmapper.exceptions import (
|
|
11
|
+
EligibilityDataError, EligibilityDownloadError, EligibilityParseError,
|
|
12
|
+
OZDataError, OZDownloadError, OZParseError,
|
|
13
|
+
)
|
|
10
14
|
from nmtcmapper.data.schema import (
|
|
11
15
|
CACHE_DIR, CDFI_FUND_LIC_URL_2020,
|
|
12
16
|
ELIGIBILITY_FILE_COLUMNS,
|
|
@@ -37,33 +41,68 @@ def download_eligibility_file(force: bool = False) -> Path:
|
|
|
37
41
|
print(f"Using cached eligibility file: {path}")
|
|
38
42
|
return path
|
|
39
43
|
print("Downloading NMTC eligibility file from CDFI Fund...")
|
|
44
|
+
# Stream to a .part temp and atomically rename on success, so a mid-stream
|
|
45
|
+
# failure can never leave a truncated file at the final cache path (which
|
|
46
|
+
# would make every later run parse-fail on poisoned cache).
|
|
47
|
+
tmp = path.with_suffix(path.suffix + ".part")
|
|
40
48
|
try:
|
|
41
49
|
response = requests.get(CDFI_FUND_LIC_URL_2020, stream=True, timeout=120)
|
|
42
50
|
response.raise_for_status()
|
|
43
|
-
with open(
|
|
51
|
+
with open(tmp, "wb") as f:
|
|
44
52
|
for chunk in response.iter_content(chunk_size=8192):
|
|
45
53
|
f.write(chunk)
|
|
54
|
+
tmp.replace(path)
|
|
46
55
|
print(f"Saved to {path}")
|
|
47
56
|
return path
|
|
48
|
-
except
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
except requests.exceptions.HTTPError as e:
|
|
58
|
+
tmp.unlink(missing_ok=True)
|
|
59
|
+
status = getattr(e.response, "status_code", None)
|
|
60
|
+
if status == 404:
|
|
61
|
+
reason = "not found (404) — the CDFI Fund file path may have moved"
|
|
62
|
+
elif status == 403:
|
|
63
|
+
reason = "access blocked (403 Forbidden)"
|
|
64
|
+
else:
|
|
65
|
+
reason = f"HTTP {status}"
|
|
66
|
+
raise EligibilityDownloadError(
|
|
67
|
+
f"Failed to download NMTC eligibility file from "
|
|
68
|
+
f"{CDFI_FUND_LIC_URL_2020}: {reason}"
|
|
69
|
+
) from e
|
|
70
|
+
except requests.exceptions.RequestException as e:
|
|
71
|
+
tmp.unlink(missing_ok=True)
|
|
72
|
+
raise EligibilityDownloadError(
|
|
73
|
+
f"Failed to download NMTC eligibility file from "
|
|
74
|
+
f"{CDFI_FUND_LIC_URL_2020}: connection/DNS/timeout error "
|
|
75
|
+
f"({type(e).__name__}: {e})"
|
|
76
|
+
) from e
|
|
51
77
|
|
|
52
78
|
|
|
53
79
|
def load_eligibility_table(force: bool = False) -> pd.DataFrame:
|
|
54
80
|
path = download_eligibility_file(force=force)
|
|
55
81
|
if path is None or not path.exists():
|
|
56
|
-
|
|
57
|
-
|
|
82
|
+
# download_eligibility_file now raises on any download failure, so a
|
|
83
|
+
# missing file here means the download step was skipped and no local
|
|
84
|
+
# copy exists. Fail loud — never substitute demo data (F2).
|
|
85
|
+
raise EligibilityDownloadError(
|
|
86
|
+
f"No eligibility file available at "
|
|
87
|
+
f"{_cache_path('NMTC_LIC_Eligibility_2016_2020.xlsb')} and no download "
|
|
88
|
+
f"was performed."
|
|
89
|
+
)
|
|
58
90
|
print(f"Loading eligibility table from {path}...")
|
|
59
91
|
try:
|
|
60
92
|
if path.suffix == ".xlsb":
|
|
61
93
|
return _load_xlsb_table(path)
|
|
62
94
|
df = pd.read_excel(path, dtype=str)
|
|
63
95
|
return _process_eligibility_table(df)
|
|
96
|
+
except EligibilityDataError:
|
|
97
|
+
raise
|
|
98
|
+
except ImportError:
|
|
99
|
+
# Missing optional engine (e.g. pyxlsb) — its own message is actionable
|
|
100
|
+
# and is a dependency problem, not a corrupt-file problem. Don't mask it.
|
|
101
|
+
raise
|
|
64
102
|
except Exception as e:
|
|
65
|
-
|
|
66
|
-
|
|
103
|
+
raise EligibilityParseError(
|
|
104
|
+
f"Failed to parse eligibility file {path}: {type(e).__name__}: {e}"
|
|
105
|
+
) from e
|
|
67
106
|
|
|
68
107
|
|
|
69
108
|
def _load_xlsb_table(path: Path) -> pd.DataFrame:
|
|
@@ -193,7 +232,16 @@ def _compute_eligibility(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
193
232
|
return df
|
|
194
233
|
|
|
195
234
|
|
|
196
|
-
def
|
|
235
|
+
def load_sample_table() -> pd.DataFrame:
|
|
236
|
+
"""Build the 12-tract synthetic demo table — an EXPLICIT opt-in only.
|
|
237
|
+
|
|
238
|
+
WARNING: this is 12 synthetic-vintage tracts for demos, examples, and tests.
|
|
239
|
+
It is NEVER valid for a real NMTC eligibility answer. Before 0.3.4 this table
|
|
240
|
+
was substituted silently whenever a download or parse failed, fabricating
|
|
241
|
+
'ineligible' results for real tracts; that path is gone. Use it only through
|
|
242
|
+
``load_sample_table()`` or ``NMTCMapper.from_sample()`` when you knowingly
|
|
243
|
+
want demo data with ``data_source == "sample"``.
|
|
244
|
+
"""
|
|
197
245
|
sample_tracts = [
|
|
198
246
|
("17031840100", 0.38, 0.55, 0.12, False, False, False),
|
|
199
247
|
("17031839100", 0.42, 0.48, 0.15, False, False, False),
|
|
@@ -226,6 +274,11 @@ def _build_sample_table() -> pd.DataFrame:
|
|
|
226
274
|
return df
|
|
227
275
|
|
|
228
276
|
|
|
277
|
+
# Backwards-compatible alias: the examples/ notebook imports the private name.
|
|
278
|
+
# Kept intentionally so that existing imports keep working after the F4 rename.
|
|
279
|
+
_build_sample_table = load_sample_table
|
|
280
|
+
|
|
281
|
+
|
|
229
282
|
# ── Opportunity Zone lookup ───────────────────────────────────────────────────
|
|
230
283
|
|
|
231
284
|
def load_opportunity_zones(force: bool = False) -> set:
|
|
@@ -236,24 +289,44 @@ def load_opportunity_zones(force: bool = False) -> set:
|
|
|
236
289
|
Source: CDFI Fund designated-qozs.12.14.18.xlsx (8,764 tracts).
|
|
237
290
|
Sheet "QOZs 14Jun", header on row 5 (index 4),
|
|
238
291
|
tract FIPS in column "Census Tract Number".
|
|
239
|
-
|
|
292
|
+
Raises OZDownloadError / OZParseError on any failure — never falls back.
|
|
240
293
|
"""
|
|
294
|
+
from nmtcmapper.data.schema import OZ_URL_2018
|
|
295
|
+
|
|
241
296
|
filename = "QOZ_Designated_2018.xlsx"
|
|
242
297
|
path = _cache_path(filename)
|
|
243
298
|
|
|
244
299
|
if not path.exists() or force:
|
|
300
|
+
# Same atomic .part-then-rename pattern as download_eligibility_file —
|
|
301
|
+
# a mid-stream failure must never poison the final cache path.
|
|
302
|
+
tmp = path.with_suffix(path.suffix + ".part")
|
|
245
303
|
try:
|
|
246
|
-
from nmtcmapper.data.schema import OZ_URL_2018
|
|
247
304
|
print("Downloading Opportunity Zone tract list...")
|
|
248
305
|
response = requests.get(OZ_URL_2018, stream=True, timeout=60)
|
|
249
306
|
response.raise_for_status()
|
|
250
|
-
with open(
|
|
307
|
+
with open(tmp, "wb") as f:
|
|
251
308
|
for chunk in response.iter_content(chunk_size=8192):
|
|
252
309
|
f.write(chunk)
|
|
310
|
+
tmp.replace(path)
|
|
253
311
|
print(f"Saved to {path}")
|
|
254
|
-
except
|
|
255
|
-
|
|
256
|
-
|
|
312
|
+
except requests.exceptions.HTTPError as e:
|
|
313
|
+
tmp.unlink(missing_ok=True)
|
|
314
|
+
status = getattr(e.response, "status_code", None)
|
|
315
|
+
if status == 404:
|
|
316
|
+
reason = "not found (404) — the OZ file path may have moved"
|
|
317
|
+
elif status == 403:
|
|
318
|
+
reason = "access blocked (403 Forbidden)"
|
|
319
|
+
else:
|
|
320
|
+
reason = f"HTTP {status}"
|
|
321
|
+
raise OZDownloadError(
|
|
322
|
+
f"Failed to download Opportunity Zone file from {OZ_URL_2018}: {reason}"
|
|
323
|
+
) from e
|
|
324
|
+
except requests.exceptions.RequestException as e:
|
|
325
|
+
tmp.unlink(missing_ok=True)
|
|
326
|
+
raise OZDownloadError(
|
|
327
|
+
f"Failed to download Opportunity Zone file from {OZ_URL_2018}: "
|
|
328
|
+
f"connection/DNS/timeout error ({type(e).__name__}: {e})"
|
|
329
|
+
) from e
|
|
257
330
|
|
|
258
331
|
try:
|
|
259
332
|
df = pd.read_excel(
|
|
@@ -269,15 +342,27 @@ def load_opportunity_zones(force: bool = False) -> set:
|
|
|
269
342
|
tracts = set(df[col].dropna().str.strip().str.zfill(11).tolist())
|
|
270
343
|
print(f"Loaded {len(tracts):,} Opportunity Zone tracts")
|
|
271
344
|
return tracts
|
|
272
|
-
|
|
345
|
+
except OZDataError:
|
|
346
|
+
raise
|
|
273
347
|
except Exception as e:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
348
|
+
raise OZParseError(
|
|
349
|
+
f"Failed to parse Opportunity Zone file {path}: {type(e).__name__}: {e}"
|
|
350
|
+
) from e
|
|
351
|
+
|
|
352
|
+
# File parsed, but none of the known tract columns were present — fail loud
|
|
353
|
+
# rather than degrade to the 6-tract sample (F3).
|
|
354
|
+
raise OZParseError(
|
|
355
|
+
f"Opportunity Zone file {path} parsed but no tract column was found "
|
|
356
|
+
f"(looked for CENSUS TRACT NUMBER / GEOID / CENSUS_TRACT / TRACT_ID / TRACT)."
|
|
357
|
+
)
|
|
277
358
|
|
|
278
359
|
|
|
279
360
|
def _sample_oz_tracts() -> set:
|
|
280
|
-
"""
|
|
361
|
+
"""6 known OZ tracts — EXPLICIT opt-in only (used by NMTCMapper.from_sample()).
|
|
362
|
+
|
|
363
|
+
As of 0.3.4 this is never reached from a download/parse failure path; OZ
|
|
364
|
+
failures raise OZDownloadError / OZParseError instead of degrading to this set.
|
|
365
|
+
"""
|
|
281
366
|
return {
|
|
282
367
|
"17031840100", # Chicago South Side
|
|
283
368
|
"17031839100", # Chicago West Side
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed exception hierarchy for nmtc-mapper (F1, added 0.3.4).
|
|
3
|
+
|
|
4
|
+
Before 0.3.4 the loader silently substituted a synthetic sample dataset on ANY
|
|
5
|
+
download or parse failure, so a blocked network or a corrupt file produced a
|
|
6
|
+
*fabricated* eligibility answer with no signal to the caller. These exceptions
|
|
7
|
+
replace that behavior: every data-acquisition failure now raises, and the
|
|
8
|
+
message names the URL attempted and distinguishes access-blocked (403 /
|
|
9
|
+
connection / DNS) from not-found (404) from parse — so a caught error can never
|
|
10
|
+
be mistaken for a different problem (the HMDA 0.3.1 lesson: a typed error with a
|
|
11
|
+
misleading message misdirects just as badly as a swallowed one).
|
|
12
|
+
|
|
13
|
+
NMTCMapperError
|
|
14
|
+
├─ EligibilityDataError
|
|
15
|
+
│ ├─ EligibilityDownloadError # 403 / 404 / DNS / timeout / connection
|
|
16
|
+
│ └─ EligibilityParseError # corrupt / wrong content-type / missing sheet / bad zip
|
|
17
|
+
└─ OZDataError
|
|
18
|
+
├─ OZDownloadError
|
|
19
|
+
└─ OZParseError
|
|
20
|
+
|
|
21
|
+
Downstream code can catch at any level: `except NMTCMapperError` for anything,
|
|
22
|
+
`except EligibilityDataError` for eligibility-only, or a specific leaf.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NMTCMapperError(Exception):
|
|
27
|
+
"""Base class for every error raised by nmtc-mapper."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EligibilityDataError(NMTCMapperError):
|
|
31
|
+
"""The CDFI Fund NMTC eligibility dataset could not be obtained."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EligibilityDownloadError(EligibilityDataError):
|
|
35
|
+
"""Could not download the eligibility file (403 / 404 / DNS / timeout / connection),
|
|
36
|
+
and no usable cached copy was available."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EligibilityParseError(EligibilityDataError):
|
|
40
|
+
"""The eligibility file was obtained but could not be parsed
|
|
41
|
+
(corrupt bytes, wrong content-type / HTML error page, missing sheet, bad zip)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OZDataError(NMTCMapperError):
|
|
45
|
+
"""The Opportunity Zone designation dataset could not be obtained."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OZDownloadError(OZDataError):
|
|
49
|
+
"""Could not download the Opportunity Zone file (403 / 404 / DNS / timeout / connection),
|
|
50
|
+
and no usable cached copy was available."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OZParseError(OZDataError):
|
|
54
|
+
"""The Opportunity Zone file was obtained but could not be parsed
|
|
55
|
+
(corrupt bytes, wrong content-type, missing sheet / tract column, bad zip)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"NMTCMapperError",
|
|
60
|
+
"EligibilityDataError",
|
|
61
|
+
"EligibilityDownloadError",
|
|
62
|
+
"EligibilityParseError",
|
|
63
|
+
"OZDataError",
|
|
64
|
+
"OZDownloadError",
|
|
65
|
+
"OZParseError",
|
|
66
|
+
]
|
|
@@ -4,7 +4,10 @@ NMTCMapper — main public API for NMTC eligibility checking.
|
|
|
4
4
|
import pandas as pd
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from nmtcmapper.data.loader import
|
|
7
|
+
from nmtcmapper.data.loader import (
|
|
8
|
+
load_eligibility_table, load_opportunity_zones,
|
|
9
|
+
load_sample_table, _sample_oz_tracts,
|
|
10
|
+
)
|
|
8
11
|
from nmtcmapper.geocoder.census import geocode_address, geocode_batch
|
|
9
12
|
from nmtcmapper.eligibility.checker import (
|
|
10
13
|
check_tract, enrich_dataframe, EligibilityResult
|
|
@@ -32,7 +35,12 @@ class NMTCMapper:
|
|
|
32
35
|
|
|
33
36
|
def __init__(self, force_reload: bool = False):
|
|
34
37
|
"""
|
|
35
|
-
Initialize NMTCMapper
|
|
38
|
+
Initialize NMTCMapper against the real CDFI Fund data.
|
|
39
|
+
|
|
40
|
+
Raises on any download or parse failure (EligibilityDownloadError /
|
|
41
|
+
EligibilityParseError / OZDownloadError / OZParseError) rather than
|
|
42
|
+
silently substituting demo data. For offline demos/tests, use the
|
|
43
|
+
explicit ``NMTCMapper.from_sample()`` constructor instead.
|
|
36
44
|
|
|
37
45
|
Args:
|
|
38
46
|
force_reload: Re-download the eligibility file even if cached
|
|
@@ -42,6 +50,29 @@ class NMTCMapper:
|
|
|
42
50
|
print(f"Ready. {len(self._table):,} census tracts loaded.")
|
|
43
51
|
self._oz_tracts = load_opportunity_zones()
|
|
44
52
|
print(f"Opportunity Zones loaded: {len(self._oz_tracts):,} tracts")
|
|
53
|
+
self.data_source = "cdfi_fund"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_sample(cls) -> "NMTCMapper":
|
|
57
|
+
"""
|
|
58
|
+
Construct a mapper on the built-in synthetic sample data — no network.
|
|
59
|
+
|
|
60
|
+
WARNING: the sample is 12 synthetic-vintage tracts (+ 6 OZ tracts) for
|
|
61
|
+
demos, examples, and tests. It is NEVER valid for a real NMTC eligibility
|
|
62
|
+
answer. The resulting mapper is stamped ``data_source == "sample"`` so
|
|
63
|
+
downstream code can assert provenance. Use ``NMTCMapper()`` for real data.
|
|
64
|
+
"""
|
|
65
|
+
obj = cls.__new__(cls)
|
|
66
|
+
obj._table = load_sample_table()
|
|
67
|
+
obj._oz_tracts = _sample_oz_tracts()
|
|
68
|
+
obj.data_source = "sample"
|
|
69
|
+
return obj
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str:
|
|
72
|
+
return (
|
|
73
|
+
f"NMTCMapper(data_source={self.data_source!r}, "
|
|
74
|
+
f"tracts={len(self._table):,}, oz_tracts={len(self._oz_tracts):,})"
|
|
75
|
+
)
|
|
45
76
|
|
|
46
77
|
def check_address(self, address: str) -> EligibilityResult:
|
|
47
78
|
"""
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nmtc-mapper"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Automated NMTC eligibility checker — geocode addresses and check Low-Income Community status using CDFI Fund and Census data"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -19,5 +19,15 @@ dependencies = [
|
|
|
19
19
|
"pyxlsb>=1.0.0",
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=7.0"]
|
|
24
|
+
|
|
22
25
|
[project.urls]
|
|
23
26
|
Homepage = "https://github.com/Jaypatel1511/nmtc-mapper"
|
|
27
|
+
|
|
28
|
+
# Explicit discovery: the repo root also contains a generated `site/` (mkdocs
|
|
29
|
+
# output) and `docs/`, `examples/` dirs. With the setup.py pin removed, build-time
|
|
30
|
+
# auto-discovery would error on multiple top-level candidates — pin it to the
|
|
31
|
+
# real package. (find_packages() in the old setup.py did this implicitly.)
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["nmtcmapper*"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed exception hierarchy (F1) — importable from the package top level,
|
|
2
|
+
with the inheritance contract downstream catch-blocks will rely on."""
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_exceptions_importable_from_top_level():
|
|
7
|
+
from nmtcmapper import (
|
|
8
|
+
NMTCMapperError,
|
|
9
|
+
EligibilityDataError, EligibilityDownloadError, EligibilityParseError,
|
|
10
|
+
OZDataError, OZDownloadError, OZParseError,
|
|
11
|
+
)
|
|
12
|
+
assert issubclass(NMTCMapperError, Exception)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_eligibility_hierarchy():
|
|
16
|
+
from nmtcmapper import (
|
|
17
|
+
NMTCMapperError, EligibilityDataError,
|
|
18
|
+
EligibilityDownloadError, EligibilityParseError,
|
|
19
|
+
)
|
|
20
|
+
assert issubclass(EligibilityDataError, NMTCMapperError)
|
|
21
|
+
assert issubclass(EligibilityDownloadError, EligibilityDataError)
|
|
22
|
+
assert issubclass(EligibilityParseError, EligibilityDataError)
|
|
23
|
+
# download and parse are siblings, not each other
|
|
24
|
+
assert not issubclass(EligibilityDownloadError, EligibilityParseError)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_oz_hierarchy():
|
|
28
|
+
from nmtcmapper import (
|
|
29
|
+
NMTCMapperError, OZDataError, OZDownloadError, OZParseError,
|
|
30
|
+
)
|
|
31
|
+
assert issubclass(OZDataError, NMTCMapperError)
|
|
32
|
+
assert issubclass(OZDownloadError, OZDataError)
|
|
33
|
+
assert issubclass(OZParseError, OZDataError)
|
|
34
|
+
assert not issubclass(OZDownloadError, OZParseError)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_a_single_except_catches_every_data_error():
|
|
38
|
+
"""The whole point: one `except NMTCMapperError` catches all six leaves."""
|
|
39
|
+
from nmtcmapper import (
|
|
40
|
+
NMTCMapperError,
|
|
41
|
+
EligibilityDownloadError, EligibilityParseError,
|
|
42
|
+
OZDownloadError, OZParseError,
|
|
43
|
+
)
|
|
44
|
+
for exc in (EligibilityDownloadError, EligibilityParseError,
|
|
45
|
+
OZDownloadError, OZParseError):
|
|
46
|
+
with pytest.raises(NMTCMapperError):
|
|
47
|
+
raise exc("boom")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""F2/F3 — fail loud, never fabricate.
|
|
2
|
+
|
|
3
|
+
Every eligibility/OZ download or parse failure must RAISE a typed error instead
|
|
4
|
+
of silently returning a synthetic sample (0.3.3 behavior — see repro evidence in
|
|
5
|
+
the 0.3.4 changelog). These are the negative-case tests: under 0.3.3 each returns
|
|
6
|
+
a fabricated frame/set; under 0.3.4 each raises.
|
|
7
|
+
"""
|
|
8
|
+
import pytest
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
import nmtcmapper.data.loader as loader
|
|
12
|
+
from nmtcmapper.data.loader import load_eligibility_table, load_opportunity_zones
|
|
13
|
+
from nmtcmapper import (
|
|
14
|
+
NMTCMapper,
|
|
15
|
+
EligibilityDownloadError, EligibilityParseError,
|
|
16
|
+
OZDownloadError, OZParseError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ELIG_FILENAME = "NMTC_LIC_Eligibility_2016_2020.xlsb"
|
|
20
|
+
OZ_FILENAME = "QOZ_Designated_2018.xlsx"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def isolated_cache(tmp_path, monkeypatch):
|
|
25
|
+
"""Point the loader at an empty cache dir so no real cached file interferes."""
|
|
26
|
+
monkeypatch.setattr(loader, "CACHE_DIR", str(tmp_path))
|
|
27
|
+
return tmp_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _block_network(monkeypatch, exc=None):
|
|
31
|
+
def _boom(*a, **k):
|
|
32
|
+
raise exc or requests.exceptions.ConnectionError(
|
|
33
|
+
"simulated blocked egress / DNS failure"
|
|
34
|
+
)
|
|
35
|
+
monkeypatch.setattr(loader.requests, "get", _boom)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Eligibility ───────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def test_download_failure_raises(isolated_cache, monkeypatch):
|
|
41
|
+
_block_network(monkeypatch)
|
|
42
|
+
with pytest.raises(EligibilityDownloadError):
|
|
43
|
+
load_eligibility_table(force=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_download_failure_mapper_constructor_raises(isolated_cache, monkeypatch):
|
|
47
|
+
_block_network(monkeypatch)
|
|
48
|
+
with pytest.raises(EligibilityDownloadError):
|
|
49
|
+
NMTCMapper()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_corrupt_file_raises(isolated_cache):
|
|
53
|
+
(isolated_cache / ELIG_FILENAME).write_bytes(b"\x00\x01\x02 not a zip garbage")
|
|
54
|
+
with pytest.raises(EligibilityParseError):
|
|
55
|
+
load_eligibility_table(force=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_html_error_page_raises(isolated_cache):
|
|
59
|
+
# A 403/404 HTML error page saved under the .xlsb name (a real CDN failure mode).
|
|
60
|
+
(isolated_cache / ELIG_FILENAME).write_bytes(
|
|
61
|
+
b"<!DOCTYPE html><html><body><h1>403 Forbidden</h1></body></html>"
|
|
62
|
+
)
|
|
63
|
+
with pytest.raises(EligibilityParseError):
|
|
64
|
+
load_eligibility_table(force=False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_old_sample_on_failure_now_raises(isolated_cache, monkeypatch):
|
|
68
|
+
"""The explicit old-behavior guard: blocked egress raises, and crucially NO
|
|
69
|
+
12-row sample frame is returned in its place."""
|
|
70
|
+
_block_network(monkeypatch)
|
|
71
|
+
sentinel = object()
|
|
72
|
+
returned = sentinel
|
|
73
|
+
with pytest.raises(EligibilityDownloadError):
|
|
74
|
+
returned = load_eligibility_table(force=True)
|
|
75
|
+
# Nothing escaped the raise — in particular, not a fabricated 12-tract frame.
|
|
76
|
+
assert returned is sentinel
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Opportunity Zones ─────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def test_oz_download_failure_raises(isolated_cache, monkeypatch):
|
|
82
|
+
_block_network(monkeypatch)
|
|
83
|
+
with pytest.raises(OZDownloadError):
|
|
84
|
+
load_opportunity_zones(force=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_oz_parse_failure_raises(isolated_cache):
|
|
88
|
+
(isolated_cache / OZ_FILENAME).write_bytes(b"not a real xlsx \x00\x01 garbage")
|
|
89
|
+
with pytest.raises(OZParseError):
|
|
90
|
+
load_opportunity_zones(force=False)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _DroppingResponse:
|
|
94
|
+
"""Mocks a download whose connection drops mid-stream after one chunk."""
|
|
95
|
+
def raise_for_status(self):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def iter_content(self, chunk_size=8192):
|
|
99
|
+
yield b"PARTIAL_CONTENT_" * 512
|
|
100
|
+
raise requests.exceptions.ChunkedEncodingError("connection dropped mid-stream")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_partial_download_cleanup_and_retry(isolated_cache, monkeypatch):
|
|
104
|
+
"""H2: a mid-stream download failure must raise the typed download error AND
|
|
105
|
+
leave nothing at the final cache path (no poisoned partial file, no .part
|
|
106
|
+
temp) so the next attempt re-downloads instead of parse-failing forever."""
|
|
107
|
+
monkeypatch.setattr(loader.requests, "get", lambda *a, **k: _DroppingResponse())
|
|
108
|
+
with pytest.raises(EligibilityDownloadError):
|
|
109
|
+
load_eligibility_table(force=True)
|
|
110
|
+
assert not (isolated_cache / ELIG_FILENAME).exists(), \
|
|
111
|
+
"partial download left a poisoned file at the final cache path"
|
|
112
|
+
assert list(isolated_cache.glob("*.part")) == [], ".part temp file left behind"
|
|
113
|
+
|
|
114
|
+
# Second attempt: the mock now succeeds. force=False proves no poisoned
|
|
115
|
+
# cache short-circuits the retry — the loader must actually re-download.
|
|
116
|
+
payload = b"FULL_CONTENT" * 100
|
|
117
|
+
calls = []
|
|
118
|
+
|
|
119
|
+
class _GoodResponse:
|
|
120
|
+
def raise_for_status(self):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def iter_content(self, chunk_size=8192):
|
|
124
|
+
yield payload
|
|
125
|
+
|
|
126
|
+
def _good_get(*a, **k):
|
|
127
|
+
calls.append(1)
|
|
128
|
+
return _GoodResponse()
|
|
129
|
+
|
|
130
|
+
monkeypatch.setattr(loader.requests, "get", _good_get)
|
|
131
|
+
path = loader.download_eligibility_file(force=False)
|
|
132
|
+
assert calls, "retry never re-downloaded (a leftover file short-circuited it)"
|
|
133
|
+
assert path == isolated_cache / ELIG_FILENAME
|
|
134
|
+
assert path.read_bytes() == payload
|
|
135
|
+
assert list(isolated_cache.glob("*.part")) == []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_oz_partial_download_cleanup_and_retry(isolated_cache, monkeypatch):
|
|
139
|
+
"""H2, OZ twin — mid-stream drop raises OZDownloadError with a clean cache,
|
|
140
|
+
then a successful retry re-downloads and the full load works end-to-end."""
|
|
141
|
+
import io
|
|
142
|
+
import openpyxl
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr(loader.requests, "get", lambda *a, **k: _DroppingResponse())
|
|
145
|
+
with pytest.raises(OZDownloadError):
|
|
146
|
+
load_opportunity_zones(force=True)
|
|
147
|
+
assert not (isolated_cache / OZ_FILENAME).exists(), \
|
|
148
|
+
"partial OZ download left a poisoned file at the final cache path"
|
|
149
|
+
assert list(isolated_cache.glob("*.part")) == [], ".part temp file left behind"
|
|
150
|
+
|
|
151
|
+
# Retry serves a valid OZ workbook -> construction works end-to-end.
|
|
152
|
+
wb = openpyxl.Workbook()
|
|
153
|
+
ws = wb.active
|
|
154
|
+
ws.title = "QOZs 14Jun"
|
|
155
|
+
for _ in range(4): # junk rows 1-4; header on row 5 (index 4)
|
|
156
|
+
ws.append([""])
|
|
157
|
+
ws.append(["Census Tract Number"])
|
|
158
|
+
ws.append(["17031840100"])
|
|
159
|
+
buf = io.BytesIO()
|
|
160
|
+
wb.save(buf)
|
|
161
|
+
data = buf.getvalue()
|
|
162
|
+
|
|
163
|
+
class _GoodResponse:
|
|
164
|
+
def raise_for_status(self):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def iter_content(self, chunk_size=8192):
|
|
168
|
+
yield data
|
|
169
|
+
|
|
170
|
+
monkeypatch.setattr(loader.requests, "get", lambda *a, **k: _GoodResponse())
|
|
171
|
+
oz = load_opportunity_zones(force=False)
|
|
172
|
+
assert oz == {"17031840100"}
|
|
173
|
+
assert list(isolated_cache.glob("*.part")) == []
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_oz_missing_tract_column_raises(isolated_cache):
|
|
177
|
+
"""A structurally-valid xlsx whose tract column is absent must raise, not
|
|
178
|
+
degrade to the 6-tract sample (0.3.3 loader.py:272)."""
|
|
179
|
+
import openpyxl
|
|
180
|
+
wb = openpyxl.Workbook()
|
|
181
|
+
ws = wb.active
|
|
182
|
+
ws.title = "QOZs 14Jun"
|
|
183
|
+
for _ in range(4): # junk rows 1-4; header is on row 5 (index 4)
|
|
184
|
+
ws.append(["", "", ""])
|
|
185
|
+
ws.append(["WRONG_COLUMN", "OTHER"]) # header row, no tract column
|
|
186
|
+
ws.append(["17031840100", "x"]) # a data row
|
|
187
|
+
wb.save(isolated_cache / OZ_FILENAME)
|
|
188
|
+
with pytest.raises(OZParseError):
|
|
189
|
+
load_opportunity_zones(force=False)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""F4 — explicit sample mode.
|
|
2
|
+
|
|
3
|
+
Sample data survives only as a deliberate, provenance-marked opt-in. It must
|
|
4
|
+
NEVER be reachable implicitly from a failure path (that's F2/F3), and when used
|
|
5
|
+
explicitly it must (a) require no network, and (b) stamp the mapper so downstream
|
|
6
|
+
code can tell a demo answer from a real one.
|
|
7
|
+
"""
|
|
8
|
+
import pytest
|
|
9
|
+
from nmtcmapper.mapper import NMTCMapper
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_load_sample_table_public():
|
|
13
|
+
from nmtcmapper import load_sample_table
|
|
14
|
+
df = load_sample_table()
|
|
15
|
+
assert len(df) == 12
|
|
16
|
+
assert df["nmtc_eligible"].any()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_underscore_alias_preserved_for_notebook():
|
|
20
|
+
# examples/ notebook imports the private name — keep it working.
|
|
21
|
+
from nmtcmapper.data.loader import _build_sample_table, load_sample_table
|
|
22
|
+
assert len(_build_sample_table()) == 12
|
|
23
|
+
assert _build_sample_table is load_sample_table
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_from_sample_explicit_works(monkeypatch):
|
|
27
|
+
# The whole contract: zero network. Any requests.get is a hard failure.
|
|
28
|
+
def _no_network(*a, **k):
|
|
29
|
+
raise AssertionError("from_sample() must not touch the network")
|
|
30
|
+
monkeypatch.setattr("nmtcmapper.data.loader.requests.get", _no_network)
|
|
31
|
+
|
|
32
|
+
m = NMTCMapper.from_sample()
|
|
33
|
+
assert m.tract_count == 12
|
|
34
|
+
assert m.oz_tract_count == 6
|
|
35
|
+
assert m.data_source == "sample"
|
|
36
|
+
# sample data still answers the known eligible demo tract
|
|
37
|
+
assert m.check_tract("17031840100").nmtc_eligible is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_data_source_marker(monkeypatch):
|
|
41
|
+
"""Real-path constructor stamps data_source == 'cdfi_fund' (mocked success)."""
|
|
42
|
+
from nmtcmapper import load_sample_table
|
|
43
|
+
fake_table = load_sample_table() # any real-shaped frame stands in for the CDFI download
|
|
44
|
+
monkeypatch.setattr(
|
|
45
|
+
"nmtcmapper.mapper.load_eligibility_table", lambda force=False: fake_table
|
|
46
|
+
)
|
|
47
|
+
monkeypatch.setattr(
|
|
48
|
+
"nmtcmapper.mapper.load_opportunity_zones", lambda force=False: {"17031840100"}
|
|
49
|
+
)
|
|
50
|
+
m = NMTCMapper()
|
|
51
|
+
assert m.data_source == "cdfi_fund"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_data_source_surfaced_in_repr():
|
|
55
|
+
m = NMTCMapper.from_sample()
|
|
56
|
+
assert "sample" in repr(m).lower()
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
from nmtcmapper.mapper import NMTCMapper
|
|
2
|
-
from nmtcmapper.eligibility.checker import EligibilityResult
|
|
3
|
-
from nmtcmapper.data.loader import load_eligibility_table
|
|
4
|
-
from nmtcmapper.geocoder.census import geocode_address
|
|
5
|
-
|
|
6
|
-
__version__ = "0.1.0"
|
|
7
|
-
__all__ = [
|
|
8
|
-
"NMTCMapper", "EligibilityResult",
|
|
9
|
-
"load_eligibility_table", "geocode_address",
|
|
10
|
-
]
|
nmtc_mapper-0.3.2/setup.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
from setuptools import setup, find_packages
|
|
2
|
-
|
|
3
|
-
setup(
|
|
4
|
-
name="nmtc-mapper",
|
|
5
|
-
version="0.3.0",
|
|
6
|
-
packages=find_packages(),
|
|
7
|
-
install_requires=[
|
|
8
|
-
"pandas>=1.4.0",
|
|
9
|
-
"numpy>=1.21.0",
|
|
10
|
-
"requests>=2.27.0",
|
|
11
|
-
"aiohttp>=3.8.0",
|
|
12
|
-
"tqdm>=4.64.0",
|
|
13
|
-
"openpyxl>=3.0.0",
|
|
14
|
-
],
|
|
15
|
-
)
|
|
File without changes
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
import pandas as pd
|
|
3
|
-
from nmtcmapper.data.loader import _build_sample_table
|
|
4
|
-
from nmtcmapper.mapper import NMTCMapper
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@pytest.fixture
|
|
8
|
-
def sample_table():
|
|
9
|
-
return _build_sample_table()
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@pytest.fixture
|
|
13
|
-
def mapper(monkeypatch):
|
|
14
|
-
"""NMTCMapper with sample data — no real download."""
|
|
15
|
-
monkeypatch.setattr(
|
|
16
|
-
"nmtcmapper.data.loader.download_eligibility_file",
|
|
17
|
-
lambda force=False: None
|
|
18
|
-
)
|
|
19
|
-
return NMTCMapper()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@pytest.fixture
|
|
23
|
-
def sample_df():
|
|
24
|
-
return pd.DataFrame({
|
|
25
|
-
"project_name": [
|
|
26
|
-
"Southside Health Center",
|
|
27
|
-
"North Shore Office",
|
|
28
|
-
"Detroit Manufacturing",
|
|
29
|
-
"NYC Bronx Project",
|
|
30
|
-
],
|
|
31
|
-
"tract_id": [
|
|
32
|
-
"17031840100", # Chicago South Side — eligible
|
|
33
|
-
"17031010100", # Chicago North Shore — not eligible
|
|
34
|
-
"26163518300", # Detroit — eligible
|
|
35
|
-
"36061015900", # NYC Bronx — eligible
|
|
36
|
-
]
|
|
37
|
-
})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|