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.
- addrforge/__init__.py +22 -0
- addrforge/cli.py +71 -0
- addrforge/data.py +626 -0
- addrforge/errors.py +9 -0
- addrforge/format.py +71 -0
- addrforge/lines.py +26 -0
- addrforge/models.py +96 -0
- addrforge/normalize.py +77 -0
- addrforge/parser.py +371 -0
- addrforge/patterns.py +138 -0
- addrforge/validation.py +255 -0
- addrforge-0.1.0.dist-info/METADATA +141 -0
- addrforge-0.1.0.dist-info/RECORD +17 -0
- addrforge-0.1.0.dist-info/WHEEL +5 -0
- addrforge-0.1.0.dist-info/entry_points.txt +2 -0
- addrforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- addrforge-0.1.0.dist-info/top_level.txt +1 -0
addrforge/lines.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Address line splitting helpers."""
|
|
2
|
+
|
|
3
|
+
from .format import primary_line, unit_line
|
|
4
|
+
from .models import AddressLines
|
|
5
|
+
from .parser import parse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def split_lines(text: str, *, strict: bool = False) -> AddressLines:
|
|
9
|
+
"""Split an address into mailing-style line fields.
|
|
10
|
+
|
|
11
|
+
This is a formatter built on top of :func:`addrforge.parse`; it does not
|
|
12
|
+
validate deliverability or infer missing components.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
parsed = parse(text, strict=strict)
|
|
16
|
+
line1 = primary_line(parsed)
|
|
17
|
+
line2 = unit_line(parsed)
|
|
18
|
+
return AddressLines(
|
|
19
|
+
raw=text,
|
|
20
|
+
line1=line1,
|
|
21
|
+
line2=line2,
|
|
22
|
+
city=parsed.city,
|
|
23
|
+
state=parsed.state,
|
|
24
|
+
zip_code=parsed.zip_code,
|
|
25
|
+
standardized=parsed.standardized,
|
|
26
|
+
)
|
addrforge/models.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Data models used by addrforge."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from typing import Any, Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ParsedAddress:
|
|
10
|
+
"""Structured result returned by :func:`addrforge.parse`."""
|
|
11
|
+
|
|
12
|
+
raw: str
|
|
13
|
+
kind: str = "unknown"
|
|
14
|
+
number: Optional[str] = None
|
|
15
|
+
predir: Optional[str] = None
|
|
16
|
+
street_name: Optional[str] = None
|
|
17
|
+
suffix: Optional[str] = None
|
|
18
|
+
postdir: Optional[str] = None
|
|
19
|
+
unit_type: Optional[str] = None
|
|
20
|
+
unit_id: Optional[str] = None
|
|
21
|
+
route: Optional[str] = None
|
|
22
|
+
po_box: Optional[str] = None
|
|
23
|
+
city: Optional[str] = None
|
|
24
|
+
state: Optional[str] = None
|
|
25
|
+
zip_code: Optional[str] = None
|
|
26
|
+
confidence: float = 0.0
|
|
27
|
+
match_level: str = "unknown"
|
|
28
|
+
components_missing: Tuple[str, ...] = ()
|
|
29
|
+
warnings: Tuple[str, ...] = ()
|
|
30
|
+
parse_notes: Tuple[str, ...] = ()
|
|
31
|
+
is_complete_for_mailing: bool = False
|
|
32
|
+
is_us: bool = True
|
|
33
|
+
reject_reason: Optional[str] = None
|
|
34
|
+
standardized: str = ""
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
37
|
+
"""Return a JSON-serializable dictionary."""
|
|
38
|
+
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
def to_json(self, *, indent: Optional[int] = None) -> str:
|
|
42
|
+
"""Return a JSON string representation."""
|
|
43
|
+
|
|
44
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class AddressLines:
|
|
49
|
+
"""US mailing-style line split derived from a parsed address."""
|
|
50
|
+
|
|
51
|
+
raw: str
|
|
52
|
+
line1: str = ""
|
|
53
|
+
line2: str = ""
|
|
54
|
+
city: Optional[str] = None
|
|
55
|
+
state: Optional[str] = None
|
|
56
|
+
zip_code: Optional[str] = None
|
|
57
|
+
standardized: str = ""
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
60
|
+
"""Return a JSON-serializable dictionary."""
|
|
61
|
+
|
|
62
|
+
return asdict(self)
|
|
63
|
+
|
|
64
|
+
def to_json(self, *, indent: Optional[int] = None) -> str:
|
|
65
|
+
"""Return a JSON string representation."""
|
|
66
|
+
|
|
67
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ValidationResult:
|
|
72
|
+
"""Result from an optional external address lookup provider."""
|
|
73
|
+
|
|
74
|
+
raw: str
|
|
75
|
+
provider: str
|
|
76
|
+
parsed: ParsedAddress
|
|
77
|
+
is_valid: bool = False
|
|
78
|
+
is_deliverable: Optional[bool] = None
|
|
79
|
+
match_level: str = "unknown"
|
|
80
|
+
confidence: float = 0.0
|
|
81
|
+
matched_address: Optional[str] = None
|
|
82
|
+
latitude: Optional[float] = None
|
|
83
|
+
longitude: Optional[float] = None
|
|
84
|
+
warnings: Tuple[str, ...] = ()
|
|
85
|
+
error: Optional[str] = None
|
|
86
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
89
|
+
"""Return a JSON-serializable dictionary."""
|
|
90
|
+
|
|
91
|
+
return asdict(self)
|
|
92
|
+
|
|
93
|
+
def to_json(self, *, indent: Optional[int] = None) -> str:
|
|
94
|
+
"""Return a JSON string representation."""
|
|
95
|
+
|
|
96
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
addrforge/normalize.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Component-level normalization helpers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .data import DIRECTIONALS, STREET_SUFFIXES, UNIT_TYPES
|
|
7
|
+
|
|
8
|
+
_SPACE_RE = re.compile(r"\s+")
|
|
9
|
+
_PUNCT_TO_SPACE_RE = re.compile(r"[;|]+")
|
|
10
|
+
_DOT_RE = re.compile(r"\.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def collapse_spaces(text: str) -> str:
|
|
14
|
+
"""Return *text* with repeated whitespace collapsed."""
|
|
15
|
+
|
|
16
|
+
return _SPACE_RE.sub(" ", text).strip()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def preprocess(text: str) -> str:
|
|
20
|
+
"""Normalize punctuation and spacing while preserving useful hyphens."""
|
|
21
|
+
|
|
22
|
+
cleaned = _DOT_RE.sub("", text.strip())
|
|
23
|
+
cleaned = _PUNCT_TO_SPACE_RE.sub(" ", cleaned)
|
|
24
|
+
cleaned = re.sub(r"\s*,\s*", ", ", cleaned)
|
|
25
|
+
cleaned = re.sub(r"\s*#\s*", " #", cleaned)
|
|
26
|
+
return collapse_spaces(cleaned.strip(" ,"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def token_key(value: Optional[str]) -> Optional[str]:
|
|
30
|
+
"""Normalize a token for dictionary lookup."""
|
|
31
|
+
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
key = value.strip().upper().replace(".", "")
|
|
35
|
+
return collapse_spaces(key)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normalize_directional(value: Optional[str]) -> Optional[str]:
|
|
39
|
+
"""Normalize a directional token to USPS-style abbreviation."""
|
|
40
|
+
|
|
41
|
+
key = token_key(value)
|
|
42
|
+
return DIRECTIONALS.get(key or "")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_unit_type(value: Optional[str]) -> Optional[str]:
|
|
46
|
+
"""Normalize a unit designator."""
|
|
47
|
+
|
|
48
|
+
key = token_key(value)
|
|
49
|
+
return UNIT_TYPES.get(key or "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_suffix(value: Optional[str]) -> Optional[str]:
|
|
53
|
+
"""Normalize a street suffix to a USPS-like abbreviation."""
|
|
54
|
+
|
|
55
|
+
key = token_key(value)
|
|
56
|
+
return STREET_SUFFIXES.get(key or "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_text(value: Optional[str]) -> Optional[str]:
|
|
60
|
+
"""Uppercase and collapse whitespace for free-text components."""
|
|
61
|
+
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
normalized = collapse_spaces(value.strip(" ,").upper())
|
|
65
|
+
return normalized or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_city(value: Optional[str]) -> Optional[str]:
|
|
69
|
+
"""Normalize a city name without attempting validation."""
|
|
70
|
+
|
|
71
|
+
return normalize_text(value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_zip(value: Optional[str]) -> Optional[str]:
|
|
75
|
+
"""Normalize ZIP or ZIP+4 text."""
|
|
76
|
+
|
|
77
|
+
return normalize_text(value)
|
addrforge/parser.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Main parsing pipeline for addrforge."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .data import CANADIAN_PROVINCES, NON_US_COUNTRY_TERMS, STATE_ABBREVIATIONS
|
|
8
|
+
from .format import format_standardized
|
|
9
|
+
from .models import ParsedAddress
|
|
10
|
+
from .normalize import (
|
|
11
|
+
collapse_spaces,
|
|
12
|
+
normalize_city,
|
|
13
|
+
normalize_directional,
|
|
14
|
+
normalize_suffix,
|
|
15
|
+
normalize_text,
|
|
16
|
+
normalize_unit_type,
|
|
17
|
+
normalize_zip,
|
|
18
|
+
preprocess,
|
|
19
|
+
)
|
|
20
|
+
from .patterns import (
|
|
21
|
+
CITY_STATE_ZIP_RE,
|
|
22
|
+
MAILBOX_ROUTE_RE,
|
|
23
|
+
PO_BOX_RE,
|
|
24
|
+
ROUTE_RE,
|
|
25
|
+
STATE_ZIP_RE,
|
|
26
|
+
STREET_RE,
|
|
27
|
+
UNIT_FIRST_RE,
|
|
28
|
+
UNIT_RE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_CANADIAN_POSTAL_RE = re.compile(
|
|
32
|
+
r"\b[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z]\s?\d[ABCEGHJ-NPRSTV-Z]\d\b",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse(text: str, *, strict: bool = False) -> ParsedAddress:
|
|
38
|
+
"""Parse a US address string into structured components.
|
|
39
|
+
|
|
40
|
+
The parser is intentionally forgiving: it returns a partial result with
|
|
41
|
+
``kind="unknown"`` when the input does not resemble a supported address.
|
|
42
|
+
It does not validate existence or deliverability. Set ``strict=True`` to
|
|
43
|
+
reject partial parses that are not complete enough for mailing workflows.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
raw = text
|
|
47
|
+
cleaned = preprocess(text or "")
|
|
48
|
+
if not cleaned:
|
|
49
|
+
return _finalize(ParsedAddress(raw=raw, standardized=""), strict=strict)
|
|
50
|
+
|
|
51
|
+
non_us_reason = _detect_obvious_non_us(cleaned)
|
|
52
|
+
if non_us_reason:
|
|
53
|
+
return _finalize(
|
|
54
|
+
ParsedAddress(
|
|
55
|
+
raw=raw,
|
|
56
|
+
kind="unknown",
|
|
57
|
+
is_us=False,
|
|
58
|
+
reject_reason=non_us_reason,
|
|
59
|
+
warnings=("non_us_address",),
|
|
60
|
+
parse_notes=("non_us_address",),
|
|
61
|
+
),
|
|
62
|
+
strict=strict,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
body, city, state, zip_code = _split_place_tail(cleaned)
|
|
66
|
+
body, unit_type, unit_id = _split_trailing_unit(body)
|
|
67
|
+
if unit_type is None:
|
|
68
|
+
body, unit_type, unit_id = _split_leading_unit(body)
|
|
69
|
+
|
|
70
|
+
parsed = _parse_po_box(raw, body) or _parse_mailbox_route(raw, body) or _parse_route(raw, body) or _parse_street(raw, body)
|
|
71
|
+
if parsed is None:
|
|
72
|
+
parsed = ParsedAddress(raw=raw, kind="unknown")
|
|
73
|
+
|
|
74
|
+
parsed = replace(
|
|
75
|
+
parsed,
|
|
76
|
+
unit_type=unit_type,
|
|
77
|
+
unit_id=unit_id,
|
|
78
|
+
city=city,
|
|
79
|
+
state=state,
|
|
80
|
+
zip_code=zip_code,
|
|
81
|
+
)
|
|
82
|
+
return _finalize(parsed, strict=strict)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def standardize(text: str, *, strict: bool = False) -> str:
|
|
86
|
+
"""Return a USPS-like uppercase normalized address string."""
|
|
87
|
+
|
|
88
|
+
return parse(text, strict=strict).standardized
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def explain(text: str, *, strict: bool = False) -> Tuple[str, ...]:
|
|
92
|
+
"""Return parse notes explaining partial or ambiguous results."""
|
|
93
|
+
|
|
94
|
+
return parse(text, strict=strict).parse_notes
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_probably_address(text: str) -> bool:
|
|
98
|
+
"""Return True when *text* resembles a supported US address form."""
|
|
99
|
+
|
|
100
|
+
parsed = parse(text)
|
|
101
|
+
if parsed.kind != "unknown" and parsed.is_us:
|
|
102
|
+
return True
|
|
103
|
+
return bool(parsed.state and (parsed.city or parsed.zip_code))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _split_place_tail(text: str) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
|
|
107
|
+
for pattern in (CITY_STATE_ZIP_RE, STATE_ZIP_RE):
|
|
108
|
+
match = pattern.match(text)
|
|
109
|
+
if not match:
|
|
110
|
+
continue
|
|
111
|
+
state = normalize_text(match.group("state"))
|
|
112
|
+
body = (match.group("body") or "").strip(" ,")
|
|
113
|
+
if state not in STATE_ABBREVIATIONS or not body:
|
|
114
|
+
continue
|
|
115
|
+
city = normalize_city(match.groupdict().get("city"))
|
|
116
|
+
zip_code = normalize_zip(match.groupdict().get("zip"))
|
|
117
|
+
return body, city, state, zip_code
|
|
118
|
+
return text, None, None, None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _split_trailing_unit(text: str) -> Tuple[str, Optional[str], Optional[str]]:
|
|
122
|
+
match = UNIT_RE.match(text)
|
|
123
|
+
if not match:
|
|
124
|
+
return text, None, None
|
|
125
|
+
|
|
126
|
+
prefix = collapse_spaces((match.group("prefix") or "").strip(" ,"))
|
|
127
|
+
if not prefix:
|
|
128
|
+
return text, None, None
|
|
129
|
+
|
|
130
|
+
unit_type = normalize_unit_type(match.group("unit_type")) or "UNIT"
|
|
131
|
+
unit_id = normalize_text(match.group("unit_id"))
|
|
132
|
+
return prefix, unit_type, unit_id
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _split_leading_unit(text: str) -> Tuple[str, Optional[str], Optional[str]]:
|
|
136
|
+
match = UNIT_FIRST_RE.match(text)
|
|
137
|
+
if not match:
|
|
138
|
+
return text, None, None
|
|
139
|
+
|
|
140
|
+
body = collapse_spaces((match.group("body") or "").strip(" ,"))
|
|
141
|
+
if not body:
|
|
142
|
+
return text, None, None
|
|
143
|
+
if (normalize_text(body) or "").startswith("BOX "):
|
|
144
|
+
return text, None, None
|
|
145
|
+
|
|
146
|
+
unit_type = normalize_unit_type(match.group("unit_type")) or "UNIT"
|
|
147
|
+
unit_id = normalize_text(match.group("unit_id"))
|
|
148
|
+
return body, unit_type, unit_id
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_po_box(raw: str, body: str) -> Optional[ParsedAddress]:
|
|
152
|
+
match = PO_BOX_RE.match(body)
|
|
153
|
+
if not match:
|
|
154
|
+
return None
|
|
155
|
+
phrase = normalize_text(match.group("po_box"))
|
|
156
|
+
po_box = phrase
|
|
157
|
+
for prefix in ("PO BOX ", "POST OFFICE BOX "):
|
|
158
|
+
if phrase and phrase.startswith(prefix):
|
|
159
|
+
po_box = phrase[len(prefix) :]
|
|
160
|
+
break
|
|
161
|
+
return ParsedAddress(raw=raw, kind="po_box", po_box=po_box)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _parse_mailbox_route(raw: str, body: str) -> Optional[ParsedAddress]:
|
|
165
|
+
match = MAILBOX_ROUTE_RE.match(body)
|
|
166
|
+
if not match:
|
|
167
|
+
return None
|
|
168
|
+
po_box = _normalize_mailbox_phrase(match.group("po_box"))
|
|
169
|
+
return ParsedAddress(raw=raw, kind="po_box", po_box=po_box)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_route(raw: str, body: str) -> Optional[ParsedAddress]:
|
|
173
|
+
match = ROUTE_RE.match(body)
|
|
174
|
+
if not match:
|
|
175
|
+
return None
|
|
176
|
+
return ParsedAddress(raw=raw, kind="route", route=_normalize_route(match.group("route")))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_street(raw: str, body: str) -> Optional[ParsedAddress]:
|
|
180
|
+
match = STREET_RE.match(body)
|
|
181
|
+
if not match:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
number = normalize_text(match.group("number"))
|
|
185
|
+
predir = normalize_directional(match.group("predir"))
|
|
186
|
+
street_name = normalize_text(match.group("street_name"))
|
|
187
|
+
suffix = normalize_suffix(match.group("suffix"))
|
|
188
|
+
postdir = normalize_directional(match.group("postdir"))
|
|
189
|
+
|
|
190
|
+
if not street_name:
|
|
191
|
+
return None
|
|
192
|
+
if not (number or suffix or predir or postdir):
|
|
193
|
+
return None
|
|
194
|
+
if street_name in STATE_ABBREVIATIONS and not suffix:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
return ParsedAddress(
|
|
198
|
+
raw=raw,
|
|
199
|
+
kind="street",
|
|
200
|
+
number=number,
|
|
201
|
+
predir=predir,
|
|
202
|
+
street_name=street_name,
|
|
203
|
+
suffix=suffix,
|
|
204
|
+
postdir=postdir,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _normalize_route(value: str) -> str:
|
|
209
|
+
text = normalize_text(value) or ""
|
|
210
|
+
text = text.replace("U S HIGHWAY", "US")
|
|
211
|
+
text = text.replace("I ", "I-")
|
|
212
|
+
text = text.replace("US-", "US ")
|
|
213
|
+
text = text.replace("US ", "US ")
|
|
214
|
+
return collapse_spaces(text)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _normalize_mailbox_phrase(value: str) -> str:
|
|
218
|
+
text = normalize_text(value) or ""
|
|
219
|
+
text = text.replace("RURAL ROUTE", "RR")
|
|
220
|
+
text = text.replace("HIGHWAY CONTRACT", "HC")
|
|
221
|
+
return collapse_spaces(text)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _finalize(address: ParsedAddress, *, strict: bool) -> ParsedAddress:
|
|
225
|
+
enriched = _enrich(address)
|
|
226
|
+
if strict and not enriched.is_complete_for_mailing:
|
|
227
|
+
enriched = ParsedAddress(
|
|
228
|
+
raw=address.raw,
|
|
229
|
+
kind="unknown",
|
|
230
|
+
is_us=address.is_us,
|
|
231
|
+
reject_reason=address.reject_reason or "strict_incomplete_address",
|
|
232
|
+
warnings=tuple(dict.fromkeys(enriched.warnings + ("strict_incomplete_address",))),
|
|
233
|
+
parse_notes=tuple(dict.fromkeys(enriched.parse_notes + ("strict_incomplete_address",))),
|
|
234
|
+
)
|
|
235
|
+
enriched = _enrich(enriched)
|
|
236
|
+
return replace(enriched, standardized=format_standardized(enriched))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _enrich(address: ParsedAddress) -> ParsedAddress:
|
|
240
|
+
missing = _components_missing(address)
|
|
241
|
+
complete = address.kind != "unknown" and address.is_us and not missing
|
|
242
|
+
score = _score(address)
|
|
243
|
+
notes = _notes(address)
|
|
244
|
+
warnings = _warnings(address, missing, complete, score)
|
|
245
|
+
match_level = _match_level(address, complete, score)
|
|
246
|
+
return replace(
|
|
247
|
+
address,
|
|
248
|
+
confidence=score,
|
|
249
|
+
match_level=match_level,
|
|
250
|
+
components_missing=missing,
|
|
251
|
+
warnings=tuple(dict.fromkeys(address.warnings + warnings)),
|
|
252
|
+
parse_notes=tuple(dict.fromkeys(address.parse_notes + notes)),
|
|
253
|
+
is_complete_for_mailing=complete,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _score(address: ParsedAddress) -> float:
|
|
258
|
+
if not address.is_us:
|
|
259
|
+
return 0.0
|
|
260
|
+
if address.kind == "unknown":
|
|
261
|
+
return 0.2 if address.state else 0.0
|
|
262
|
+
|
|
263
|
+
score = 0.55
|
|
264
|
+
if address.kind == "po_box":
|
|
265
|
+
score = 0.9 if address.po_box else 0.65
|
|
266
|
+
elif address.kind == "route":
|
|
267
|
+
score = 0.88 if address.route else 0.6
|
|
268
|
+
elif address.kind == "street":
|
|
269
|
+
score = 0.45
|
|
270
|
+
if address.number:
|
|
271
|
+
score += 0.2
|
|
272
|
+
if address.street_name:
|
|
273
|
+
score += 0.15
|
|
274
|
+
if address.suffix:
|
|
275
|
+
score += 0.15
|
|
276
|
+
if address.predir or address.postdir:
|
|
277
|
+
score += 0.05
|
|
278
|
+
|
|
279
|
+
if address.unit_type and address.unit_id:
|
|
280
|
+
score += 0.03
|
|
281
|
+
if address.city and address.state:
|
|
282
|
+
score += 0.04
|
|
283
|
+
elif address.state:
|
|
284
|
+
score += 0.02
|
|
285
|
+
if address.zip_code:
|
|
286
|
+
score += 0.03
|
|
287
|
+
|
|
288
|
+
return round(min(score, 0.99), 2)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _components_missing(address: ParsedAddress) -> Tuple[str, ...]:
|
|
292
|
+
missing = []
|
|
293
|
+
if not address.is_us:
|
|
294
|
+
missing.append("us_address")
|
|
295
|
+
return tuple(missing)
|
|
296
|
+
if address.kind == "unknown":
|
|
297
|
+
missing.append("primary_address")
|
|
298
|
+
return tuple(missing)
|
|
299
|
+
if address.kind == "street":
|
|
300
|
+
if not address.number:
|
|
301
|
+
missing.append("number")
|
|
302
|
+
if not address.street_name:
|
|
303
|
+
missing.append("street_name")
|
|
304
|
+
if not address.suffix:
|
|
305
|
+
missing.append("suffix")
|
|
306
|
+
elif address.kind == "route" and not address.route:
|
|
307
|
+
missing.append("route")
|
|
308
|
+
elif address.kind == "po_box" and not address.po_box:
|
|
309
|
+
missing.append("po_box")
|
|
310
|
+
|
|
311
|
+
if not address.city:
|
|
312
|
+
missing.append("city")
|
|
313
|
+
if not address.state:
|
|
314
|
+
missing.append("state")
|
|
315
|
+
if not address.zip_code:
|
|
316
|
+
missing.append("zip_code")
|
|
317
|
+
return tuple(missing)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _match_level(address: ParsedAddress, complete: bool, score: float) -> str:
|
|
321
|
+
if address.kind == "unknown" or not address.is_us:
|
|
322
|
+
return "unknown"
|
|
323
|
+
if complete and score >= 0.9:
|
|
324
|
+
return "exact-ish"
|
|
325
|
+
if score >= 0.75:
|
|
326
|
+
return "partial"
|
|
327
|
+
return "weak"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _warnings(address: ParsedAddress, missing: Tuple[str, ...], complete: bool, score: float) -> Tuple[str, ...]:
|
|
331
|
+
warnings = []
|
|
332
|
+
if not address.is_us:
|
|
333
|
+
warnings.append("non_us_address")
|
|
334
|
+
if address.kind == "unknown":
|
|
335
|
+
warnings.append("unparsed_address")
|
|
336
|
+
if missing and address.kind != "unknown" and not complete:
|
|
337
|
+
warnings.append("not_complete_for_mailing")
|
|
338
|
+
if address.kind != "unknown" and score < 0.8:
|
|
339
|
+
warnings.append("low_confidence_parse")
|
|
340
|
+
return tuple(warnings)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _notes(address: ParsedAddress) -> Tuple[str, ...]:
|
|
344
|
+
notes = []
|
|
345
|
+
if not address.is_us:
|
|
346
|
+
notes.append("non_us_address")
|
|
347
|
+
if address.kind == "unknown":
|
|
348
|
+
notes.append("no_supported_address_pattern")
|
|
349
|
+
if address.kind == "street" and not address.number:
|
|
350
|
+
notes.append("missing_house_number")
|
|
351
|
+
if address.kind == "street" and not address.suffix:
|
|
352
|
+
notes.append("missing_street_suffix")
|
|
353
|
+
if address.kind != "unknown" and not (address.city or address.state or address.zip_code):
|
|
354
|
+
notes.append("missing_place_tail")
|
|
355
|
+
if address.state and not address.city:
|
|
356
|
+
notes.append("state_without_city")
|
|
357
|
+
return tuple(notes)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _detect_obvious_non_us(text: str) -> Optional[str]:
|
|
361
|
+
normalized = normalize_text(text) or ""
|
|
362
|
+
for term in sorted(NON_US_COUNTRY_TERMS, key=len, reverse=True):
|
|
363
|
+
if re.search(rf"\b{re.escape(term)}\b", normalized):
|
|
364
|
+
return "non_us_country"
|
|
365
|
+
|
|
366
|
+
if _CANADIAN_POSTAL_RE.search(normalized):
|
|
367
|
+
tokens = set(normalized.replace(",", " ").split())
|
|
368
|
+
if tokens.intersection(CANADIAN_PROVINCES):
|
|
369
|
+
return "canadian_postal_code"
|
|
370
|
+
|
|
371
|
+
return None
|
addrforge/patterns.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Compiled regular expressions for addrforge."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .data import DIRECTIONALS, STREET_SUFFIXES, UNIT_TYPES
|
|
6
|
+
|
|
7
|
+
_DIRECTIONAL_PATTERN = "|".join(sorted((re.escape(k) for k in DIRECTIONALS), key=len, reverse=True))
|
|
8
|
+
_SUFFIX_PATTERN = "|".join(sorted((re.escape(k) for k in STREET_SUFFIXES), key=len, reverse=True))
|
|
9
|
+
_UNIT_PATTERN = "|".join(sorted((re.escape(k) for k in UNIT_TYPES), key=len, reverse=True))
|
|
10
|
+
|
|
11
|
+
CITY_STATE_ZIP_RE = re.compile(
|
|
12
|
+
r"""
|
|
13
|
+
^(?P<body>.*?)
|
|
14
|
+
(?:,\s*|\s+)
|
|
15
|
+
(?P<city>[A-Za-z][A-Za-z .'-]*?)
|
|
16
|
+
,?\s+
|
|
17
|
+
(?P<state>[A-Za-z]{2})
|
|
18
|
+
(?:\s+(?P<zip>\d{5}(?:-\d{4})?))?
|
|
19
|
+
\s*$
|
|
20
|
+
""",
|
|
21
|
+
re.IGNORECASE | re.VERBOSE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
STATE_ZIP_RE = re.compile(
|
|
25
|
+
r"""
|
|
26
|
+
^(?P<body>.*?)
|
|
27
|
+
,?\s+
|
|
28
|
+
(?P<state>[A-Za-z]{2})
|
|
29
|
+
(?:\s+(?P<zip>\d{5}(?:-\d{4})?))?
|
|
30
|
+
\s*$
|
|
31
|
+
""",
|
|
32
|
+
re.IGNORECASE | re.VERBOSE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
PO_BOX_RE = re.compile(
|
|
36
|
+
r"""
|
|
37
|
+
^\s*
|
|
38
|
+
(?P<po_box>
|
|
39
|
+
(?:
|
|
40
|
+
P\s*O\s+BOX
|
|
41
|
+
| POST\s+OFFICE\s+BOX
|
|
42
|
+
)
|
|
43
|
+
\s+[A-Za-z0-9-]+
|
|
44
|
+
)
|
|
45
|
+
\s*$
|
|
46
|
+
""",
|
|
47
|
+
re.IGNORECASE | re.VERBOSE,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
MAILBOX_ROUTE_RE = re.compile(
|
|
51
|
+
r"""
|
|
52
|
+
^\s*
|
|
53
|
+
(?P<po_box>
|
|
54
|
+
(?:
|
|
55
|
+
RR|RURAL\s+ROUTE
|
|
56
|
+
)
|
|
57
|
+
\s+\d+[A-Za-z]?
|
|
58
|
+
\s+BOX\s+[A-Za-z0-9-]+
|
|
59
|
+
|
|
|
60
|
+
(?:
|
|
61
|
+
HC|HIGHWAY\s+CONTRACT
|
|
62
|
+
)
|
|
63
|
+
\s+\d+[A-Za-z]?
|
|
64
|
+
\s+BOX\s+[A-Za-z0-9-]+
|
|
65
|
+
|
|
|
66
|
+
(?:
|
|
67
|
+
PSC|CMR
|
|
68
|
+
)
|
|
69
|
+
\s+\d+[A-Za-z]?
|
|
70
|
+
\s+BOX\s+[A-Za-z0-9-]+
|
|
71
|
+
|
|
|
72
|
+
UNIT\s+\d+[A-Za-z]?
|
|
73
|
+
\s+BOX\s+[A-Za-z0-9-]+
|
|
74
|
+
)
|
|
75
|
+
\s*$
|
|
76
|
+
""",
|
|
77
|
+
re.IGNORECASE | re.VERBOSE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
ROUTE_RE = re.compile(
|
|
81
|
+
r"""
|
|
82
|
+
^\s*
|
|
83
|
+
(?P<route>
|
|
84
|
+
I[-\s]?\d+[A-Za-z]?
|
|
85
|
+
| US[-\s]?\d+[A-Za-z]?
|
|
86
|
+
| U\s*S\s+HIGHWAY\s+\d+[A-Za-z]?
|
|
87
|
+
| STATE\s+(?:ROUTE|ROAD|HIGHWAY)\s+\d+[A-Za-z]?
|
|
88
|
+
| COUNTY\s+(?:ROAD|HIGHWAY|ROUTE)\s+\d+[A-Za-z]?
|
|
89
|
+
| FARM\s+TO\s+MARKET\s+ROAD\s+\d+[A-Za-z]?
|
|
90
|
+
| RANCH\s+TO\s+MARKET\s+ROAD\s+\d+[A-Za-z]?
|
|
91
|
+
| ROUTE\s+\d+[A-Za-z]?
|
|
92
|
+
)
|
|
93
|
+
\s*$
|
|
94
|
+
""",
|
|
95
|
+
re.IGNORECASE | re.VERBOSE,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
UNIT_RE = re.compile(
|
|
99
|
+
rf"""
|
|
100
|
+
(?P<prefix>.*?)
|
|
101
|
+
(?:\s+|\s*,\s*|^)
|
|
102
|
+
(?:
|
|
103
|
+
(?P<unit_type>{_UNIT_PATTERN})\s+
|
|
104
|
+
| \#\s*
|
|
105
|
+
)
|
|
106
|
+
(?P<unit_id>[A-Za-z0-9][A-Za-z0-9-]*)
|
|
107
|
+
\s*$
|
|
108
|
+
""",
|
|
109
|
+
re.IGNORECASE | re.VERBOSE,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
UNIT_FIRST_RE = re.compile(
|
|
113
|
+
rf"""
|
|
114
|
+
^\s*
|
|
115
|
+
(?:
|
|
116
|
+
(?P<unit_type>{_UNIT_PATTERN})\s+
|
|
117
|
+
| \#\s*
|
|
118
|
+
)
|
|
119
|
+
(?P<unit_id>[A-Za-z0-9][A-Za-z0-9-]*)
|
|
120
|
+
\s+
|
|
121
|
+
(?P<body>.+?)
|
|
122
|
+
\s*$
|
|
123
|
+
""",
|
|
124
|
+
re.IGNORECASE | re.VERBOSE,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
STREET_RE = re.compile(
|
|
128
|
+
rf"""
|
|
129
|
+
^\s*
|
|
130
|
+
(?:(?P<number>\d+[A-Za-z]?(?:-\d+[A-Za-z]?)?)\s+)?
|
|
131
|
+
(?:(?P<predir>{_DIRECTIONAL_PATTERN})\s+)?
|
|
132
|
+
(?P<street_name>.*?)
|
|
133
|
+
(?:\s+(?P<suffix>{_SUFFIX_PATTERN}))?
|
|
134
|
+
(?:\s+(?P<postdir>{_DIRECTIONAL_PATTERN}))?
|
|
135
|
+
\s*$
|
|
136
|
+
""",
|
|
137
|
+
re.IGNORECASE | re.VERBOSE,
|
|
138
|
+
)
|