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.
Files changed (30) hide show
  1. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/PKG-INFO +43 -2
  2. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/README.md +40 -1
  3. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/PKG-INFO +43 -2
  4. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/SOURCES.txt +5 -3
  5. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/requires.txt +3 -0
  6. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/top_level.txt +0 -1
  7. nmtc_mapper-0.3.4/nmtcmapper/__init__.py +24 -0
  8. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/data/loader.py +105 -20
  9. nmtc_mapper-0.3.4/nmtcmapper/exceptions.py +66 -0
  10. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/mapper.py +33 -2
  11. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/pyproject.toml +11 -1
  12. nmtc_mapper-0.3.4/setup.py +6 -0
  13. nmtc_mapper-0.3.4/tests/test_exceptions.py +47 -0
  14. nmtc_mapper-0.3.4/tests/test_fail_loud.py +189 -0
  15. nmtc_mapper-0.3.4/tests/test_sample_mode.py +56 -0
  16. nmtc_mapper-0.3.2/nmtcmapper/__init__.py +0 -10
  17. nmtc_mapper-0.3.2/setup.py +0 -15
  18. nmtc_mapper-0.3.2/tests/__init__.py +0 -0
  19. nmtc_mapper-0.3.2/tests/conftest.py +0 -37
  20. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtc_mapper.egg-info/dependency_links.txt +0 -0
  21. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/data/__init__.py +0 -0
  22. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/data/schema.py +0 -0
  23. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/eligibility/__init__.py +0 -0
  24. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/eligibility/checker.py +0 -0
  25. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/geocoder/__init__.py +0 -0
  26. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/nmtcmapper/geocoder/census.py +0 -0
  27. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/setup.cfg +0 -0
  28. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/tests/test_checker.py +0 -0
  29. {nmtc_mapper-0.3.2 → nmtc_mapper-0.3.4}/tests/test_loader.py +0 -0
  30. {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.2
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
- 24 tests across all modules.
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
- 24 tests across all modules.
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.2
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
- 24 tests across all modules.
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
@@ -5,3 +5,6 @@ numpy>=1.21.0
5
5
  requests>=2.27.0
6
6
  openpyxl>=3.1.0
7
7
  pyxlsb>=1.0.0
8
+
9
+ [dev]
10
+ pytest>=7.0
@@ -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(path, "wb") as f:
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 Exception as e:
49
- print(f"Download failed: {e}")
50
- return None
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
- print("Using built-in sample eligibility data.")
57
- return _build_sample_table()
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
- print(f"Error loading file: {e}. Using sample data.")
66
- return _build_sample_table()
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 _build_sample_table() -> pd.DataFrame:
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
- Falls back to a known sample set if download fails.
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(path, "wb") as f:
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 Exception as e:
255
- print(f"OZ download failed: {e}. Using sample OZ data.")
256
- return _sample_oz_tracts()
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
- print("OZ file parse error: tract column not found. Using sample data.")
345
+ except OZDataError:
346
+ raise
273
347
  except Exception as e:
274
- print(f"OZ file parse error: {e}. Using sample data.")
275
-
276
- return _sample_oz_tracts()
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
- """Known OZ tracts for testing subset of real designations."""
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 load_eligibility_table, load_opportunity_zones
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 and load the eligibility table.
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.2"
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,6 @@
1
+ # Shim retained for `pip install -e .` and legacy tooling only.
2
+ # All package metadata — including the authoritative version — lives in
3
+ # pyproject.toml ([project]). Do not re-add a version or dependency pin here.
4
+ from setuptools import setup
5
+
6
+ setup()
@@ -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
- ]
@@ -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