northpost-parser 1.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,158 @@
1
+ """Immutable parsed address components and parsing/normalization strategies."""
2
+
3
+ from dataclasses import dataclass, fields, replace
4
+ from enum import Enum
5
+
6
+
7
+ class AddressType(Enum):
8
+ CIVIC = "CIVIC"
9
+ POSTAL_BOX = "POSTAL_BOX"
10
+ RURAL_ROUTE = "RURAL_ROUTE"
11
+ GENERAL_DELIVERY = "GENERAL_DELIVERY"
12
+ MIXED = "MIXED"
13
+ INCOMPLETE = "INCOMPLETE"
14
+
15
+
16
+ class ParsingMode(Enum):
17
+ STRICT = "STRICT"
18
+ LENIENT = "LENIENT"
19
+
20
+
21
+ class ParsingStrategy(Enum):
22
+ LENIENT = "LENIENT"
23
+ STRICT = "STRICT"
24
+ STRICT_THEN_LENIENT = "STRICT_THEN_LENIENT"
25
+
26
+
27
+ class NormalizationStrategy(Enum):
28
+ FULL_FORM = "FULL_FORM"
29
+ ABBREVIATED = "ABBREVIATED"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AddressComponents:
34
+ addressee: str = ""
35
+ care_of: str = ""
36
+ unit_number: str = ""
37
+ street_number: str = ""
38
+ street_name: str = ""
39
+ street_type: str = ""
40
+ street_direction: str = ""
41
+ postal_box_number: str = ""
42
+ rural_route: str = ""
43
+ site_info: str = ""
44
+ municipality: str = ""
45
+ province: str = ""
46
+ postal_code: str = ""
47
+ country: str = ""
48
+ address_type: AddressType = AddressType.INCOMPLETE
49
+ parsing_mode: ParsingMode = ParsingMode.LENIENT
50
+ is_complete: bool = False
51
+
52
+ def __post_init__(self) -> None:
53
+ for field in fields(self):
54
+ if field.name not in {"address_type", "parsing_mode", "is_complete"}:
55
+ value = getattr(self, field.name)
56
+ object.__setattr__(self, field.name, "" if value is None else value.strip().upper())
57
+ if self.address_type is None:
58
+ raise TypeError("Address type cannot be null")
59
+ if self.parsing_mode is None:
60
+ raise TypeError("Parsing mode cannot be null")
61
+
62
+ def has_addressee(self) -> bool:
63
+ return bool(self.addressee)
64
+
65
+ def has_care_of(self) -> bool:
66
+ return bool(self.care_of)
67
+
68
+ def has_unit_number(self) -> bool:
69
+ return bool(self.unit_number)
70
+
71
+ def has_street_number(self) -> bool:
72
+ return bool(self.street_number)
73
+
74
+ def has_street_name(self) -> bool:
75
+ return bool(self.street_name)
76
+
77
+ def has_street_type(self) -> bool:
78
+ return bool(self.street_type)
79
+
80
+ def has_street_direction(self) -> bool:
81
+ return bool(self.street_direction)
82
+
83
+ def has_postal_box_number(self) -> bool:
84
+ return bool(self.postal_box_number)
85
+
86
+ def has_rural_route(self) -> bool:
87
+ return bool(self.rural_route)
88
+
89
+ def has_municipality(self) -> bool:
90
+ return bool(self.municipality)
91
+
92
+ def has_province(self) -> bool:
93
+ return bool(self.province)
94
+
95
+ def has_postal_code(self) -> bool:
96
+ return bool(self.postal_code)
97
+
98
+ def has_country(self) -> bool:
99
+ return bool(self.country)
100
+
101
+ def get_formatted_street_address(self) -> str | None:
102
+ value = f"{self.unit_number}-" if self.unit_number else ""
103
+ if self.street_number:
104
+ value += self.street_number + " "
105
+ if self.street_name:
106
+ value += " ".join(filter(None, (self.street_name, self.street_type, self.street_direction)))
107
+ return value.strip() or None
108
+
109
+ def get_full_street_name(self) -> str | None:
110
+ if not self.street_name:
111
+ return None
112
+ return " ".join(filter(None, (self.street_name, self.street_type, self.street_direction)))
113
+
114
+ def get_formatted_civic_line(self) -> str | None:
115
+ match self.address_type:
116
+ case AddressType.CIVIC:
117
+ return self.get_formatted_street_address()
118
+ case AddressType.POSTAL_BOX:
119
+ return f"PO BOX {self.postal_box_number}" if self.postal_box_number else None
120
+ case AddressType.RURAL_ROUTE:
121
+ return f"RR {self.rural_route}" if self.rural_route else None
122
+ case AddressType.GENERAL_DELIVERY:
123
+ return "GENERAL DELIVERY"
124
+ case _:
125
+ return None
126
+
127
+ def get_formatted_last_line(self) -> str | None:
128
+ value = " ".join(filter(None, (self.municipality, self.province)))
129
+ if self.postal_code:
130
+ value += (" " if value else "") + self.postal_code
131
+ return value or None
132
+
133
+ def get_formatted_address(self) -> str:
134
+ return "\n".join(filter(None, (
135
+ self.addressee,
136
+ f"C/O {self.care_of}" if self.care_of else "",
137
+ self.get_formatted_civic_line(),
138
+ self.get_formatted_last_line(),
139
+ self.country,
140
+ ))).strip()
141
+
142
+ def normalize(self, strategy: NormalizationStrategy) -> "AddressComponents":
143
+ from .normalizer import AddressNormalizer
144
+
145
+ return replace(
146
+ self,
147
+ street_type=AddressNormalizer.normalize_street_type(self.street_type, strategy),
148
+ street_direction=AddressNormalizer.normalize_direction(self.street_direction, strategy),
149
+ )
150
+
151
+ def is_mailing_valid(self) -> bool:
152
+ return bool(
153
+ self.addressee
154
+ and (self.street_name or self.postal_box_number or self.rural_route)
155
+ and self.municipality
156
+ and self.province
157
+ and self.postal_code
158
+ )
@@ -0,0 +1,107 @@
1
+ """Canada Post street-type and direction normalization tables."""
2
+
3
+ from .models import NormalizationStrategy
4
+
5
+
6
+ def _register(full: dict[str, str], abbreviated: dict[str, str],
7
+ name: str, abbreviation: str) -> None:
8
+ full[abbreviation] = name
9
+ full[name] = name
10
+ abbreviated.setdefault(name, abbreviation)
11
+ abbreviated[abbreviation] = abbreviation
12
+
13
+
14
+ def _alias(full: dict[str, str], abbreviated: dict[str, str],
15
+ abbreviation: str, name: str) -> None:
16
+ full[abbreviation] = name
17
+ abbreviated[abbreviation] = abbreviated.get(name, abbreviation)
18
+
19
+
20
+ _STREET_FULL: dict[str, str] = {}
21
+ _STREET_ABBREVIATED: dict[str, str] = {}
22
+ for _name, _abbreviation in (
23
+ ("STREET", "ST"), ("AVENUE", "AVE"), ("BOULEVARD", "BLVD"),
24
+ ("DRIVE", "DR"), ("ROAD", "RD"), ("PLACE", "PL"), ("COURT", "CRT"),
25
+ ("CRESCENT", "CRES"), ("CIRCLE", "CIR"), ("WAY", "WY"), ("LANE", "LN"),
26
+ ("TERRACE", "TERR"), ("TRAIL", "TRL"), ("PARKWAY", "PKY"), ("HIGHWAY", "HWY"),
27
+ ("CLOSE", "CL"), ("GATE", "GT"), ("GREEN", "GR"), ("GROVE", "GV"),
28
+ ("HEATH", "HE"), ("HEIGHTS", "HTS"), ("HILL", "HL"), ("LANDING", "LD"),
29
+ ("MEWS", "MS"), ("PARK", "PK"), ("POINT", "PT"), ("SQUARE", "SQ"),
30
+ ("WALK", "WK"), ("BAY", "BA"), ("BRANCH", "BR"), ("CIRCUIT", "CIRC"),
31
+ ("COMMON", "CM"), ("CONCESSION", "CON"), ("COVE", "CO"), ("CREEK", "CRK"),
32
+ ("CROSSING", "XG"), ("ESPLANADE", "ESPL"), ("ESTATES", "ESTS"),
33
+ ("EXTENSION", "EXTEN"), ("FREEWAY", "FWY"), ("GARDENS", "GDNS"),
34
+ ("INLET", "INL"), ("LINE", "LI"), ("LOOKOUT", "LKOUT"), ("MANOR", "MR"),
35
+ ("MOUNT", "MT"), ("MOUNTAIN", "MTN"), ("PASSAGE", "PASS"), ("PRIVATE", "PVT"),
36
+ ("PROMENADE", "PROM"), ("RANGE", "RG"), ("RISE", "RI"), ("SIDEROAD", "SDRD"),
37
+ ("TOWNLINE", "TL"), ("TRUNK", "TR"), ("VIEW", "VW"),
38
+ ):
39
+ _register(_STREET_FULL, _STREET_ABBREVIATED, _name, _abbreviation)
40
+
41
+ for _abbreviation, _name in (
42
+ ("AV", "AVENUE"), ("BV", "BOULEVARD"), ("DV", "DRIVE"), ("CT", "COURT"),
43
+ ("CR", "CRESCENT"), ("CI", "CIRCLE"), ("PY", "PARKWAY"), ("HY", "HIGHWAY"),
44
+ ("CLO", "CLOSE"), ("GA", "GATE"), ("GRN", "GREEN"), ("GRV", "GROVE"),
45
+ ("HT", "HEIGHTS"), ("LANDNG", "LANDING"), ("PA", "PARK"), ("CV", "COVE"),
46
+ ("CDS", "CROSSING"), ("GDN", "GARDENS"), ("PR", "PRIVATE"), ("RO", "RISE"),
47
+ ("SR", "SIDEROAD"), ("VI", "VIEW"), ("ME", "MAZE"), ("TC", "THICKET"),
48
+ ("TE", "THICKET"), ("XG", "CROSSING"),
49
+ ):
50
+ _alias(_STREET_FULL, _STREET_ABBREVIATED, _abbreviation, _name)
51
+
52
+ for _name, _abbreviation in (
53
+ ("BOULEVARD", "BOUL"), ("CHEMIN", "CH"), ("ROUTE", "RTE"),
54
+ ("IMPASSE", "IMP"), ("TERRASSE", "TSSE"), ("CARRÉ", "CAR"),
55
+ ):
56
+ _register(_STREET_FULL, _STREET_ABBREVIATED, _name, _abbreviation)
57
+ for _abbreviation, _name in (
58
+ ("BL", "BOULEVARD"), ("CMN", "CHEMIN"), ("CARRE", "CARRÉ"),
59
+ ("MONTEE", "MONTÉE"), ("COTE", "CÔTE"), ("ALLEE", "ALLÉE"), ("CRETE", "CRÊTE"),
60
+ ):
61
+ _alias(_STREET_FULL, _STREET_ABBREVIATED, _abbreviation, _name)
62
+
63
+ _DIRECTION_FULL: dict[str, str] = {}
64
+ _DIRECTION_ABBREVIATED: dict[str, str] = {}
65
+ for _name, _abbreviation in (
66
+ ("NORTH", "N"), ("SOUTH", "S"), ("EAST", "E"), ("WEST", "W"),
67
+ ("NORTHEAST", "NE"), ("NORTHWEST", "NW"), ("SOUTHEAST", "SE"), ("SOUTHWEST", "SW"),
68
+ ("NORTHBOUND", "NB"), ("SOUTHBOUND", "SB"), ("EASTBOUND", "EB"), ("WESTBOUND", "WB"),
69
+ ):
70
+ _register(_DIRECTION_FULL, _DIRECTION_ABBREVIATED, _name, _abbreviation)
71
+ for _abbreviation, _name in (
72
+ ("N.", "NORTH"), ("S.", "SOUTH"), ("E.", "EAST"), ("W.", "WEST"),
73
+ ("N.E.", "NORTHEAST"), ("N.W.", "NORTHWEST"), ("S.E.", "SOUTHEAST"), ("S.W.", "SOUTHWEST"),
74
+ ):
75
+ _alias(_DIRECTION_FULL, _DIRECTION_ABBREVIATED, _abbreviation, _name)
76
+ for _name, _abbreviation in (("NORD", "N"), ("SUD", "S"), ("EST", "E"), ("OUEST", "O")):
77
+ _DIRECTION_FULL[_name] = _name
78
+ _DIRECTION_ABBREVIATED[_name] = _abbreviation
79
+ _DIRECTION_FULL["O"] = "OUEST"
80
+ _DIRECTION_ABBREVIATED["O"] = "O"
81
+
82
+
83
+ def _normalize(value: str | None, strategy: NormalizationStrategy,
84
+ full: dict[str, str], abbreviated: dict[str, str]) -> str | None:
85
+ if not value:
86
+ return value
87
+ match strategy:
88
+ case NormalizationStrategy.FULL_FORM:
89
+ return full.get(value.upper(), value)
90
+ case NormalizationStrategy.ABBREVIATED:
91
+ return abbreviated.get(value.upper(), value)
92
+ case _:
93
+ raise ValueError(f"Unknown normalization strategy: {strategy!r}")
94
+
95
+
96
+ class AddressNormalizer:
97
+ """Normalize known values; preserve unknown and empty values unchanged."""
98
+
99
+ @staticmethod
100
+ def normalize_street_type(street_type: str | None,
101
+ strategy: NormalizationStrategy) -> str | None:
102
+ return _normalize(street_type, strategy, _STREET_FULL, _STREET_ABBREVIATED)
103
+
104
+ @staticmethod
105
+ def normalize_direction(direction: str | None,
106
+ strategy: NormalizationStrategy) -> str | None:
107
+ return _normalize(direction, strategy, _DIRECTION_FULL, _DIRECTION_ABBREVIATED)
@@ -0,0 +1,162 @@
1
+ """ANTLR-backed parsing service with strict and lenient validation."""
2
+
3
+ from collections import Counter
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+ import logging
7
+ import re
8
+
9
+ from antlr4 import CommonTokenStream, InputStream
10
+ from antlr4.error.ErrorListener import ErrorListener
11
+
12
+ from .generated.CanadianAddressLexer import CanadianAddressLexer
13
+ from .generated.CanadianAddressParser import CanadianAddressParser
14
+ from .models import AddressComponents, ParsingMode, ParsingStrategy
15
+ from .visitor import AddressComponentVisitor
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ParsingResult:
22
+ components: AddressComponents
23
+ successful: bool
24
+ errors: tuple[str, ...]
25
+ warnings: tuple[str, ...]
26
+ mode_used: ParsingMode
27
+
28
+ def __post_init__(self) -> None:
29
+ object.__setattr__(self, "errors", tuple(self.errors))
30
+ object.__setattr__(self, "warnings", tuple(self.warnings))
31
+
32
+ def has_errors(self) -> bool:
33
+ return bool(self.errors)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class BatchStatistics:
38
+ total_addresses: int
39
+ successful_parses: int
40
+ strict_mode_used: int
41
+ lenient_mode_used: int
42
+ complete_addresses: int
43
+ common_warnings: tuple[str, ...]
44
+
45
+
46
+ class _CollectingErrorListener(ErrorListener):
47
+ def __init__(self) -> None:
48
+ super().__init__()
49
+ self.errors: list[str] = []
50
+
51
+ def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
52
+ self.errors.append(f"Line {line}:{column} {msg}")
53
+
54
+
55
+ class AddressParserService:
56
+ def parse_address(
57
+ self, address_text: str | None, strategy: ParsingStrategy = ParsingStrategy.LENIENT
58
+ ) -> ParsingResult:
59
+ if address_text is None or not address_text.strip():
60
+ return ParsingResult(
61
+ AddressComponents(), False, ("Address text cannot be null or empty",),
62
+ (), ParsingMode.LENIENT,
63
+ )
64
+ normalized = re.sub(r"[ \t]+", " ", address_text.strip()).upper()
65
+ match strategy:
66
+ case ParsingStrategy.LENIENT:
67
+ return self._do_parse(normalized, ParsingMode.LENIENT)
68
+ case ParsingStrategy.STRICT:
69
+ return self._do_parse(normalized, ParsingMode.STRICT)
70
+ case ParsingStrategy.STRICT_THEN_LENIENT:
71
+ strict = self._do_parse(normalized, ParsingMode.STRICT)
72
+ if strict.successful:
73
+ return strict
74
+ lenient = self._do_parse(normalized, ParsingMode.LENIENT)
75
+ return ParsingResult(
76
+ lenient.components, lenient.successful, lenient.errors,
77
+ lenient.warnings + ("Strict parsing failed: " + "; ".join(strict.errors),),
78
+ ParsingMode.LENIENT,
79
+ )
80
+ case _:
81
+ raise ValueError(f"Unknown parsing strategy: {strategy!r}")
82
+
83
+ def _do_parse(self, address_text: str, mode: ParsingMode) -> ParsingResult:
84
+ listener = _CollectingErrorListener()
85
+ warnings: list[str] = []
86
+ try:
87
+ lexer = CanadianAddressLexer(InputStream(address_text))
88
+ lexer.removeErrorListeners()
89
+ lexer.addErrorListener(listener)
90
+ parser = CanadianAddressParser(CommonTokenStream(lexer))
91
+ parser.removeErrorListeners()
92
+ parser.addErrorListener(listener)
93
+ tree = parser.address()
94
+ if listener.errors:
95
+ return ParsingResult(
96
+ AddressComponents(parsing_mode=mode), False, tuple(listener.errors), (), mode,
97
+ )
98
+ components = AddressComponentVisitor(mode).visit(tree)
99
+ warnings.extend(self._validate_components(components))
100
+ if mode is ParsingMode.STRICT and not components.is_mailing_valid():
101
+ return ParsingResult(
102
+ components, False, tuple(self._build_strict_errors(components)),
103
+ tuple(warnings), mode,
104
+ )
105
+ return ParsingResult(components, True, (), tuple(warnings), mode)
106
+ except Exception as error:
107
+ _logger.exception("Unexpected error during parsing")
108
+ return ParsingResult(
109
+ AddressComponents(parsing_mode=mode), False,
110
+ (f"Unexpected error: {error}",), tuple(warnings), mode,
111
+ )
112
+
113
+ @staticmethod
114
+ def _build_strict_errors(components: AddressComponents) -> list[str]:
115
+ errors = ["Address is not complete for mailing purposes"]
116
+ if not components.addressee:
117
+ errors.append("Missing addressee")
118
+ if not (components.street_number or components.postal_box_number or components.rural_route):
119
+ errors.append("Missing delivery address")
120
+ if not components.municipality:
121
+ errors.append("Missing municipality")
122
+ if not components.province:
123
+ errors.append("Missing province")
124
+ if not components.postal_code:
125
+ errors.append("Missing postal code")
126
+ return errors
127
+
128
+ @staticmethod
129
+ def _validate_components(components: AddressComponents) -> list[str]:
130
+ warnings = []
131
+ if components.country:
132
+ warnings.append("Country specification is redundant for Canadian addresses")
133
+ if not components.is_mailing_valid():
134
+ warnings.append("Address may be incomplete for mailing purposes")
135
+ if (components.street_name and not components.street_number
136
+ and not components.postal_box_number and not components.rural_route):
137
+ warnings.append("Street name provided without street number")
138
+ if components.postal_code and len(re.sub(r"\s", "", components.postal_code)) != 6:
139
+ warnings.append("Postal code format may be invalid")
140
+ if sum(bool(value) for value in (
141
+ components.street_name, components.postal_box_number, components.rural_route,
142
+ )) > 1:
143
+ warnings.append("Multiple address types detected - this may be unusual")
144
+ return warnings
145
+
146
+ def parse_addresses(
147
+ self, addresses: Iterable[str | None], strategy: ParsingStrategy = ParsingStrategy.LENIENT
148
+ ) -> list[ParsingResult]:
149
+ return [self.parse_address(address, strategy) for address in addresses]
150
+
151
+ def get_batch_statistics(self, results: Iterable[ParsingResult]) -> BatchStatistics:
152
+ results = list(results)
153
+ total = len(results)
154
+ counts = Counter(warning for result in results for warning in result.warnings)
155
+ return BatchStatistics(
156
+ total,
157
+ sum(result.successful for result in results),
158
+ sum(result.mode_used is ParsingMode.STRICT for result in results),
159
+ sum(result.mode_used is ParsingMode.LENIENT for result in results),
160
+ sum(result.components.is_complete for result in results),
161
+ tuple(sorted(warning for warning, count in counts.items() if count > total * 0.1)),
162
+ )