addrforge 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,255 @@
1
+ """Optional external lookup providers for address geocodability checks."""
2
+
3
+ import json
4
+ from typing import Any, Callable, Dict, Optional, Protocol
5
+ from urllib.parse import urlencode
6
+ from urllib.request import Request, urlopen as default_urlopen
7
+
8
+ from .models import ParsedAddress, ValidationResult
9
+ from .parser import parse
10
+
11
+ Urlopen = Callable[..., Any]
12
+
13
+
14
+ class ValidationProvider(Protocol):
15
+ """Protocol implemented by optional validation providers."""
16
+
17
+ name: str
18
+
19
+ def validate(self, text: str, *, timeout: float = 5.0) -> ValidationResult:
20
+ """Validate or geocode an address string."""
21
+
22
+
23
+ class CensusGeocoderProvider:
24
+ """US Census Geocoder provider.
25
+
26
+ This checks whether an address can be matched to Census MAF/TIGER address
27
+ ranges. It is not USPS delivery validation and does not prove mailability.
28
+ """
29
+
30
+ name = "census"
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ benchmark: str = "Public_AR_Current",
36
+ base_url: str = "https://geocoding.geo.census.gov/geocoder/locations/onelineaddress",
37
+ urlopen: Optional[Urlopen] = None,
38
+ ) -> None:
39
+ self.benchmark = benchmark
40
+ self.base_url = base_url
41
+ self._urlopen = urlopen or default_urlopen
42
+
43
+ def validate(self, text: str, *, timeout: float = 5.0) -> ValidationResult:
44
+ parsed = parse(text)
45
+ if not parsed.is_us:
46
+ return _skipped_result(text, self.name, parsed, "non_us_address")
47
+
48
+ params = urlencode({"address": text, "benchmark": self.benchmark, "format": "json"})
49
+ url = f"{self.base_url}?{params}"
50
+ try:
51
+ payload = _load_json(url, self._urlopen, timeout=timeout)
52
+ except Exception as exc: # pragma: no cover - exact urllib failures vary
53
+ return _error_result(text, self.name, parsed, exc)
54
+
55
+ matches = (((payload or {}).get("result") or {}).get("addressMatches") or [])
56
+ if not matches:
57
+ return ValidationResult(
58
+ raw=text,
59
+ provider=self.name,
60
+ parsed=parsed,
61
+ is_valid=False,
62
+ is_deliverable=None,
63
+ match_level="unknown",
64
+ confidence=0.2,
65
+ warnings=("no_census_match", "not_delivery_validation"),
66
+ metadata={"query_url": url},
67
+ )
68
+
69
+ match = matches[0]
70
+ coords = match.get("coordinates") or {}
71
+ matched_address = match.get("matchedAddress")
72
+ return ValidationResult(
73
+ raw=text,
74
+ provider=self.name,
75
+ parsed=parsed,
76
+ is_valid=True,
77
+ is_deliverable=None,
78
+ match_level="exact-ish",
79
+ confidence=0.9,
80
+ matched_address=matched_address,
81
+ latitude=_float_or_none(coords.get("y")),
82
+ longitude=_float_or_none(coords.get("x")),
83
+ warnings=("not_delivery_validation",),
84
+ metadata={
85
+ "benchmark": self.benchmark,
86
+ "address_components": match.get("addressComponents") or {},
87
+ "tiger_line": match.get("tigerLine") or {},
88
+ },
89
+ )
90
+
91
+
92
+ class NominatimProvider:
93
+ """OpenStreetMap Nominatim provider.
94
+
95
+ Public Nominatim usage requires a real identifying User-Agent or Referer,
96
+ clear attribution, caching where appropriate, and low request volume.
97
+ """
98
+
99
+ name = "nominatim"
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ user_agent: str = "addrforge/0.1",
105
+ base_url: str = "https://nominatim.openstreetmap.org/search",
106
+ urlopen: Optional[Urlopen] = None,
107
+ ) -> None:
108
+ self.user_agent = user_agent
109
+ self.base_url = base_url
110
+ self._urlopen = urlopen or default_urlopen
111
+
112
+ def validate(self, text: str, *, timeout: float = 5.0) -> ValidationResult:
113
+ parsed = parse(text)
114
+ if not parsed.is_us:
115
+ return _skipped_result(text, self.name, parsed, "non_us_address")
116
+
117
+ params = urlencode(
118
+ {
119
+ "q": text,
120
+ "format": "jsonv2",
121
+ "addressdetails": "1",
122
+ "limit": "1",
123
+ "countrycodes": "us",
124
+ }
125
+ )
126
+ url = f"{self.base_url}?{params}"
127
+ request = Request(url, headers={"User-Agent": self.user_agent})
128
+ try:
129
+ payload = _load_json(request, self._urlopen, timeout=timeout)
130
+ except Exception as exc: # pragma: no cover - exact urllib failures vary
131
+ return _error_result(text, self.name, parsed, exc)
132
+
133
+ results = payload if isinstance(payload, list) else []
134
+ if not results:
135
+ return ValidationResult(
136
+ raw=text,
137
+ provider=self.name,
138
+ parsed=parsed,
139
+ is_valid=False,
140
+ is_deliverable=None,
141
+ match_level="unknown",
142
+ confidence=0.2,
143
+ warnings=("no_nominatim_match", "not_delivery_validation"),
144
+ metadata={"query_url": url},
145
+ )
146
+
147
+ result = results[0]
148
+ address = result.get("address") or {}
149
+ is_us = (address.get("country_code") or "").lower() == "us"
150
+ has_street = bool(address.get("road") or address.get("pedestrian"))
151
+ has_number = bool(address.get("house_number"))
152
+ confidence = _nominatim_confidence(result, is_us=is_us, has_street=has_street, has_number=has_number)
153
+ return ValidationResult(
154
+ raw=text,
155
+ provider=self.name,
156
+ parsed=parsed,
157
+ is_valid=is_us,
158
+ is_deliverable=None,
159
+ match_level="partial" if is_us else "unknown",
160
+ confidence=confidence,
161
+ matched_address=result.get("display_name"),
162
+ latitude=_float_or_none(result.get("lat")),
163
+ longitude=_float_or_none(result.get("lon")),
164
+ warnings=("not_delivery_validation", "requires_osm_attribution"),
165
+ metadata={
166
+ "osm_type": result.get("osm_type"),
167
+ "osm_id": result.get("osm_id"),
168
+ "address": address,
169
+ "has_house_number": has_number,
170
+ "has_street": has_street,
171
+ },
172
+ )
173
+
174
+
175
+ def validate(text: str, provider: Any = "census", *, timeout: float = 5.0) -> ValidationResult:
176
+ """Run an optional external geocodability check.
177
+
178
+ ``provider`` may be ``"census"``, ``"nominatim"``, or an object with a
179
+ ``validate(text, timeout=...)`` method. Results are not proof of USPS
180
+ deliverability.
181
+ """
182
+
183
+ selected = get_provider(provider)
184
+ return selected.validate(text, timeout=timeout)
185
+
186
+
187
+ def get_provider(provider: Any) -> ValidationProvider:
188
+ """Return a provider instance for a provider name or provider object."""
189
+
190
+ if isinstance(provider, str):
191
+ key = provider.lower()
192
+ if key == "census":
193
+ return CensusGeocoderProvider()
194
+ if key == "nominatim":
195
+ return NominatimProvider()
196
+ raise ValueError(f"unknown validation provider: {provider}")
197
+ if hasattr(provider, "validate"):
198
+ return provider
199
+ raise TypeError("provider must be a provider name or validation provider object")
200
+
201
+
202
+ def _load_json(request: Any, opener: Urlopen, *, timeout: float) -> Any:
203
+ response = opener(request, timeout=timeout)
204
+ with response:
205
+ data = response.read().decode("utf-8")
206
+ return json.loads(data)
207
+
208
+
209
+ def _skipped_result(text: str, provider: str, parsed: ParsedAddress, reason: str) -> ValidationResult:
210
+ return ValidationResult(
211
+ raw=text,
212
+ provider=provider,
213
+ parsed=parsed,
214
+ is_valid=False,
215
+ is_deliverable=None,
216
+ match_level="unknown",
217
+ confidence=0.0,
218
+ warnings=(reason,),
219
+ )
220
+
221
+
222
+ def _error_result(text: str, provider: str, parsed: ParsedAddress, exc: Exception) -> ValidationResult:
223
+ return ValidationResult(
224
+ raw=text,
225
+ provider=provider,
226
+ parsed=parsed,
227
+ is_valid=False,
228
+ is_deliverable=None,
229
+ match_level="unknown",
230
+ confidence=0.0,
231
+ warnings=("provider_error",),
232
+ error=str(exc),
233
+ )
234
+
235
+
236
+ def _nominatim_confidence(result: Dict[str, Any], *, is_us: bool, has_street: bool, has_number: bool) -> float:
237
+ if not is_us:
238
+ return 0.0
239
+ confidence = 0.45
240
+ if has_street:
241
+ confidence += 0.2
242
+ if has_number:
243
+ confidence += 0.2
244
+ try:
245
+ confidence += min(float(result.get("importance") or 0), 0.1)
246
+ except (TypeError, ValueError):
247
+ pass
248
+ return round(min(confidence, 0.9), 2)
249
+
250
+
251
+ def _float_or_none(value: Any) -> Optional[float]:
252
+ try:
253
+ return float(value)
254
+ except (TypeError, ValueError):
255
+ return None
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: addrforge
3
+ Version: 0.1.0
4
+ Summary: Dependency-free US address parser and normalizer
5
+ Author: addrforge contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/RohitMadhu/addrforge
8
+ Project-URL: Repository, https://github.com/RohitMadhu/addrforge
9
+ Project-URL: Issues, https://github.com/RohitMadhu/addrforge/issues
10
+ Keywords: address,parser,normalization,usps
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Text Processing
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # addrforge
26
+
27
+ `addrforge` is a lightweight, dependency-free Python package for parsing and standardizing messy US address strings. It returns structured components, USPS-like uppercase normalized strings, mailing-style address lines, and optional geocodability checks through free public providers.
28
+
29
+ The local parser is offline and dependency-free. Optional validation providers may call external public APIs, but they still do not prove USPS deliverability.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install addrforge
35
+ ```
36
+
37
+ For editable local development:
38
+
39
+ ```bash
40
+ pip install -e .
41
+ ```
42
+
43
+ ## Quickstart
44
+
45
+ ```python
46
+ from addrforge import explain, parse, split_lines, standardize, validate
47
+
48
+ parsed = parse("123 north main street apartment 4b")
49
+ print(parsed.kind) # street
50
+ print(parsed.number) # 123
51
+ print(parsed.predir) # N
52
+ print(parsed.suffix) # ST
53
+ print(parsed.unit_type) # APT
54
+ print(parsed.standardized) # 123 N MAIN ST APT 4B
55
+ print(parsed.confidence) # 0.99
56
+ print(parsed.parse_notes) # ('missing_place_tail',)
57
+ print(parsed.match_level) # partial
58
+
59
+ print(standardize("PO Box 45, Fairfax, VA 22030"))
60
+ # PO BOX 45 FAIRFAX VA 22030
61
+
62
+ print(explain("Main Street"))
63
+ # ('missing_house_number', 'missing_place_tail')
64
+
65
+ lines = split_lines("123 Main St Apt 4B, Fairfax VA 22030")
66
+ print(lines.line1) # 123 MAIN ST
67
+ print(lines.line2) # APT 4B
68
+
69
+ result = validate("1600 Pennsylvania Ave NW, Washington DC 20500", provider="census")
70
+ print(result.is_valid) # True when Census returns an address-range match
71
+ print(result.is_deliverable) # None; this is not USPS delivery validation
72
+ ```
73
+
74
+ For strict parsing:
75
+
76
+ ```python
77
+ parse("Main Street", strict=True).kind
78
+ # unknown
79
+ ```
80
+
81
+ For JSON:
82
+
83
+ ```python
84
+ print(parse("123 Main St").to_json(indent=2))
85
+ ```
86
+
87
+ From the command line:
88
+
89
+ ```bash
90
+ addrforge "123 north main street apartment 4b"
91
+ addrforge --json "123 Main St, Fairfax VA 22030"
92
+ addrforge --lines "123 Main St Apt 4B, Fairfax VA 22030"
93
+ ```
94
+
95
+ ## Supported Patterns
96
+
97
+ - Standard street addresses such as `123 Main Street`, `123 N Main St`, `12-14 W Elm Rd`, and `1600 Pennsylvania Avenue NW`
98
+ - Named streets without a house number such as `42nd Street`
99
+ - Unit and subaddress designators including `Apt`, `Apartment`, `Suite`, `Ste`, `Unit`, `Room`, `Floor`, `Bldg`, `Lot`, `No. 4`, `#200`, and unit-before-street forms such as `Suite 200 123 Main Street`
100
+ - Highway and route forms including `I-95`, `US 29`, `State Route 7`, `County Road 12`, `Farm to Market Road 1960`, and `Route 66`
101
+ - PO Box forms including `PO Box 45`, `P.O. Box 45`, and `Post Office Box 45`
102
+ - Rural, highway contract, and military mailbox forms such as `RR 2 Box 152`, `HC 67 Box 12`, `PSC 123 Box 456 APO AE 09012`, and `CMR 123 Box 456`
103
+ - Optional city, state, and ZIP tails such as `Fairfax, VA 22030`, `Arlington VA 22201`, and `Washington, DC 20001-1234`
104
+ - Obvious non-US addresses are rejected cleanly with `is_us=False` and `reject_reason`
105
+
106
+ Parsed results include:
107
+
108
+ - `confidence`: heuristic score from `0.0` to `0.99`
109
+ - `match_level`: `exact-ish`, `partial`, `weak`, or `unknown`
110
+ - `components_missing`: component names still needed for mailing-style completeness
111
+ - `warnings`: machine-readable warning codes
112
+ - `parse_notes`: explanation codes for partial or ambiguous parses
113
+ - `is_complete_for_mailing`: whether the parsed components include a primary line, city, state, and ZIP
114
+
115
+ These are heuristics for caller triage, not proof that an address is real.
116
+
117
+ ## Optional Validation Providers
118
+
119
+ `addrforge.validate()` supports two no-registration providers:
120
+
121
+ - `provider="census"` uses the US Census Geocoder. It can indicate whether an address is geocodable against Census address-range data.
122
+ - `provider="nominatim"` uses OpenStreetMap Nominatim. It can indicate whether OSM found a US place/address-like result.
123
+
124
+ Both providers use only the Python standard library. Both may be free to call without registration, but they are external services with their own usage policies. Nominatim requires a real identifying User-Agent and low request volume. Neither provider is a USPS/CASS/DPV deliverability validator.
125
+
126
+ ## Non-Goals
127
+
128
+ - USPS/CASS/DPV deliverability checks
129
+ - Misspelling correction
130
+ - International address parsing
131
+ - Replacing specialized address parsers for every edge case
132
+
133
+ ## Development
134
+
135
+ Run the test suite with:
136
+
137
+ ```bash
138
+ PYTHONPATH=src python -m unittest discover
139
+ ```
140
+
141
+ The library targets Python 3.9+ and uses only the Python standard library.
@@ -0,0 +1,17 @@
1
+ addrforge/__init__.py,sha256=R4qif8gf5WcLC3v2_Azhwr6qybAo6H1DemvstKa8amY,599
2
+ addrforge/cli.py,sha256=BPIilTBfvBrR_8zGiFVeYFrwCFAqTIPiWzHlLgOAQWg,2680
3
+ addrforge/data.py,sha256=PGSk3ZDrgiS69hN5NWkSSbB0pqwxyKvAlVmTJmJpvgE,11889
4
+ addrforge/errors.py,sha256=gsiKceA3eWtj8PwEMay90SNz8gasVXzQrslwIX92SrY,239
5
+ addrforge/format.py,sha256=LfuKjrQhPchKZ_BFMfUEEIlY6UtUC8lx7Hnc2BCaCrA,2081
6
+ addrforge/lines.py,sha256=OzYu031yL3Pi00UDve1wunbZVrZOUVhS0RtloGnRo90,737
7
+ addrforge/models.py,sha256=yQ3LgscWlFyZqgE2dFCa30B7KBRwCmYlPO1twLaPvlc,2794
8
+ addrforge/normalize.py,sha256=DZhYdnu7iM5bVJjH88eEem5YWd_tDkCT-RzjSQcwaoU,2103
9
+ addrforge/parser.py,sha256=hGiOAHV9_itmGkNuyBpc8QRN6y-5tR4sH2HRuECar98,12057
10
+ addrforge/patterns.py,sha256=nkNYdPMe_aGOYpUIIsAmRX0x98vZnPlnCChOuk3JvMg,2845
11
+ addrforge/validation.py,sha256=Ifa9-KsqTSta3hNzP3vyF9CuZmaseVFsu54ytvIVgzo,8672
12
+ addrforge-0.1.0.dist-info/licenses/LICENSE,sha256=AY8-IgyHqK8mBGYchYHeQmHEvX4a7gWDjXRQ_-WnXEc,1079
13
+ addrforge-0.1.0.dist-info/METADATA,sha256=Y0H9H920_F0jTV_5DpwAFG_7jYZlWZjHqB_JAfALrVI,5388
14
+ addrforge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ addrforge-0.1.0.dist-info/entry_points.txt,sha256=FpVEaxyBHzkWZZviUCqHWb_HFPDVuEX2P0vP4wtkCDA,49
16
+ addrforge-0.1.0.dist-info/top_level.txt,sha256=MGlWeqQOrAmIXUHyiUZIsTudf4mlPH7fp4FShzr6lRQ,10
17
+ addrforge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ addrforge = addrforge.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 addrforge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ addrforge