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/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
+ )