structured-address-fix 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.
- structured_address_fix/__init__.py +60 -0
- structured_address_fix/adapters/__init__.py +58 -0
- structured_address_fix/adapters/_xmlutil.py +146 -0
- structured_address_fix/adapters/heuristics/__init__.py +94 -0
- structured_address_fix/adapters/heuristics/base.py +122 -0
- structured_address_fix/adapters/heuristics/continental.py +81 -0
- structured_address_fix/adapters/heuristics/de.py +40 -0
- structured_address_fix/adapters/heuristics/fallback.py +50 -0
- structured_address_fix/adapters/heuristics/fr.py +40 -0
- structured_address_fix/adapters/heuristics/gb.py +90 -0
- structured_address_fix/adapters/heuristics/jp.py +88 -0
- structured_address_fix/adapters/heuristics/us.py +101 -0
- structured_address_fix/adapters/iso3166.py +140 -0
- structured_address_fix/adapters/xml_reader.py +207 -0
- structured_address_fix/adapters/xml_writer.py +244 -0
- structured_address_fix/adapters/xpath.py +114 -0
- structured_address_fix/config.py +48 -0
- structured_address_fix/data/iso3166_alpha2.json +251 -0
- structured_address_fix/data/rulebook_clauses/cbpr-2026.json +9 -0
- structured_address_fix/data/rulebook_clauses/generic-structured.json +5 -0
- structured_address_fix/data/rulebook_clauses/hvps-plus.json +9 -0
- structured_address_fix/data/rulebook_clauses/sepa.json +7 -0
- structured_address_fix/domain/__init__.py +53 -0
- structured_address_fix/domain/address.py +168 -0
- structured_address_fix/domain/enums.py +87 -0
- structured_address_fix/domain/findings.py +82 -0
- structured_address_fix/domain/party.py +40 -0
- structured_address_fix/domain/remediation.py +77 -0
- structured_address_fix/domain/result.py +57 -0
- structured_address_fix/errors.py +138 -0
- structured_address_fix/plugins/__init__.py +79 -0
- structured_address_fix/plugins/licensing.py +101 -0
- structured_address_fix/policies/__init__.py +55 -0
- structured_address_fix/policies/base.py +409 -0
- structured_address_fix/policies/cbpr_2026.py +108 -0
- structured_address_fix/policies/generic_structured.py +67 -0
- structured_address_fix/policies/hvps_plus.py +105 -0
- structured_address_fix/policies/registry.py +153 -0
- structured_address_fix/policies/sepa.py +80 -0
- structured_address_fix/services/__init__.py +39 -0
- structured_address_fix/services/apply_patch.py +41 -0
- structured_address_fix/services/assess.py +105 -0
- structured_address_fix/services/classify.py +56 -0
- structured_address_fix/services/facade.py +209 -0
- structured_address_fix/services/remediate.py +196 -0
- structured_address_fix-0.1.0.dist-info/METADATA +404 -0
- structured_address_fix-0.1.0.dist-info/RECORD +49 -0
- structured_address_fix-0.1.0.dist-info/WHEEL +4 -0
- structured_address_fix-0.1.0.dist-info/licenses/LICENSE +189 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""structured-address-fix: ISO 20022 postal-address remediation.
|
|
17
|
+
|
|
18
|
+
Detects, scores, and remediates non-compliant postal addresses in ISO
|
|
19
|
+
20022 payment messages ahead of the 14 November 2026 cliff, when fully
|
|
20
|
+
unstructured addresses are rejected across the major cross-border and
|
|
21
|
+
high-value schemes. The public surface is the :mod:`structured_address_fix.
|
|
22
|
+
services` facade; the domain models are re-exported here for convenience.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from structured_address_fix.domain import (
|
|
26
|
+
AddressClassification,
|
|
27
|
+
AddressedParty,
|
|
28
|
+
CanonicalAddress,
|
|
29
|
+
FindingCode,
|
|
30
|
+
MessageType,
|
|
31
|
+
PartyRole,
|
|
32
|
+
PatchOp,
|
|
33
|
+
PatchOperation,
|
|
34
|
+
PolicyId,
|
|
35
|
+
RemediationResult,
|
|
36
|
+
RemediationSuggestion,
|
|
37
|
+
RiskFinding,
|
|
38
|
+
Severity,
|
|
39
|
+
ValidationReport,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AddressClassification",
|
|
46
|
+
"AddressedParty",
|
|
47
|
+
"CanonicalAddress",
|
|
48
|
+
"FindingCode",
|
|
49
|
+
"MessageType",
|
|
50
|
+
"PartyRole",
|
|
51
|
+
"PatchOp",
|
|
52
|
+
"PatchOperation",
|
|
53
|
+
"PolicyId",
|
|
54
|
+
"RemediationResult",
|
|
55
|
+
"RemediationSuggestion",
|
|
56
|
+
"RiskFinding",
|
|
57
|
+
"Severity",
|
|
58
|
+
"ValidationReport",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Adapters layer: I/O boundaries around the pure domain.
|
|
17
|
+
|
|
18
|
+
This package translates between the outside world and the domain entities:
|
|
19
|
+
|
|
20
|
+
- :mod:`iso3166` — offline ISO 3166-1 alpha-2 and US-state validation.
|
|
21
|
+
- :mod:`heuristics` — country-aware unstructured-to-hybrid address splitting.
|
|
22
|
+
- :mod:`xpath` — ISO 20022 ``PstlAdr`` location tables and message-type
|
|
23
|
+
detection.
|
|
24
|
+
- :mod:`xml_reader` — parse a message into addressed parties (XXE-safe).
|
|
25
|
+
- :mod:`xml_writer` — apply remediation patches back onto a message.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from structured_address_fix.adapters.heuristics import (
|
|
31
|
+
HeuristicResult,
|
|
32
|
+
split_unstructured,
|
|
33
|
+
)
|
|
34
|
+
from structured_address_fix.adapters.iso3166 import (
|
|
35
|
+
ALPHA2_CODES,
|
|
36
|
+
US_STATES,
|
|
37
|
+
is_iso_3166_1_alpha_2,
|
|
38
|
+
is_us_state,
|
|
39
|
+
)
|
|
40
|
+
from structured_address_fix.adapters.xml_reader import read_addresses
|
|
41
|
+
from structured_address_fix.adapters.xml_writer import apply_operations
|
|
42
|
+
from structured_address_fix.adapters.xpath import (
|
|
43
|
+
detect_message_type,
|
|
44
|
+
paths_for,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"ALPHA2_CODES",
|
|
49
|
+
"US_STATES",
|
|
50
|
+
"HeuristicResult",
|
|
51
|
+
"apply_operations",
|
|
52
|
+
"detect_message_type",
|
|
53
|
+
"is_iso_3166_1_alpha_2",
|
|
54
|
+
"is_us_state",
|
|
55
|
+
"paths_for",
|
|
56
|
+
"read_addresses",
|
|
57
|
+
"split_unstructured",
|
|
58
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Namespace-aware XML pointer utilities shared by the reader and writer.
|
|
17
|
+
|
|
18
|
+
ISO 20022 documents use a single default namespace, so ``ElementTree``
|
|
19
|
+
renders every tag in Clark notation (``{namespace}LocalName``). These
|
|
20
|
+
helpers strip that namespace for local-name matching and resolve the
|
|
21
|
+
slash-separated JSON pointers that :mod:`xml_reader` emits and
|
|
22
|
+
:mod:`xml_writer` consumes.
|
|
23
|
+
|
|
24
|
+
Pointer grammar: ``/Seg/Seg/...`` where each ``Seg`` is either an element
|
|
25
|
+
local name or a decimal index. An index immediately follows the element-name
|
|
26
|
+
segment it disambiguates and selects the zero-based occurrence among
|
|
27
|
+
same-named siblings; a name with no following index means occurrence ``0``.
|
|
28
|
+
The first segment must be the root element's local name.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from xml.etree.ElementTree import Element
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PointerResolutionError(ValueError):
|
|
37
|
+
"""A JSON pointer did not resolve to an element in the tree."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def local_name(tag: str) -> str:
|
|
41
|
+
"""Return the local name of a (possibly namespaced) Clark-notation tag.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
tag: An element tag, optionally in ``{namespace}LocalName`` form.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The tag with any ``{namespace}`` prefix removed.
|
|
48
|
+
"""
|
|
49
|
+
if tag.startswith("{"):
|
|
50
|
+
return tag.split("}", 1)[1]
|
|
51
|
+
return tag
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def namespace_of(tag: str) -> str:
|
|
55
|
+
"""Return the namespace URI of a Clark-notation tag, or ``""``.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
tag: An element tag, optionally in ``{namespace}LocalName`` form.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The namespace URI, or the empty string if the tag is unqualified.
|
|
62
|
+
"""
|
|
63
|
+
if tag.startswith("{"):
|
|
64
|
+
return tag[1:].split("}", 1)[0]
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def children_named(parent: Element, name: str) -> list[Element]:
|
|
69
|
+
"""Return ``parent``'s direct children whose local name is ``name``.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
parent: The element whose children to scan.
|
|
73
|
+
name: The local name to match.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The matching child elements, in document order.
|
|
77
|
+
"""
|
|
78
|
+
return [child for child in parent if local_name(child.tag) == name]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def split_pointer_steps(pointer: str) -> list[tuple[str, int]]:
|
|
82
|
+
"""Parse a JSON pointer into ``(local_name, index)`` steps.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
pointer: A slash-separated pointer, e.g.
|
|
86
|
+
``"/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/0/Cdtr/PstlAdr"``.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
One ``(name, index)`` tuple per element step, with ``index``
|
|
90
|
+
defaulting to ``0`` when no explicit index segment follows.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
PointerResolutionError: if the pointer is empty or an index segment
|
|
94
|
+
has no preceding element name.
|
|
95
|
+
"""
|
|
96
|
+
raw = [seg for seg in pointer.split("/") if seg != ""]
|
|
97
|
+
if not raw:
|
|
98
|
+
raise PointerResolutionError(f"empty pointer: {pointer!r}")
|
|
99
|
+
|
|
100
|
+
steps: list[tuple[str, int]] = []
|
|
101
|
+
for seg in raw:
|
|
102
|
+
if seg.isdigit():
|
|
103
|
+
if not steps:
|
|
104
|
+
raise PointerResolutionError(
|
|
105
|
+
f"index segment with no element name: {pointer!r}"
|
|
106
|
+
)
|
|
107
|
+
name, _ = steps[-1]
|
|
108
|
+
steps[-1] = (name, int(seg))
|
|
109
|
+
else:
|
|
110
|
+
steps.append((seg, 0))
|
|
111
|
+
return steps
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_steps(root: Element, steps: list[tuple[str, int]]) -> Element:
|
|
115
|
+
"""Resolve parsed pointer ``steps`` to an element, starting at ``root``.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
root: The document root element.
|
|
119
|
+
steps: Parsed ``(name, index)`` steps; the first must name the root.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The element the steps address.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
PointerResolutionError: if the root name mismatches or any step
|
|
126
|
+
selects a missing child/occurrence.
|
|
127
|
+
"""
|
|
128
|
+
if not steps:
|
|
129
|
+
raise PointerResolutionError("cannot resolve empty steps")
|
|
130
|
+
|
|
131
|
+
root_name, _ = steps[0]
|
|
132
|
+
if local_name(root.tag) != root_name:
|
|
133
|
+
raise PointerResolutionError(
|
|
134
|
+
f"root is {local_name(root.tag)!r}, pointer expects {root_name!r}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
current = root
|
|
138
|
+
for name, index in steps[1:]:
|
|
139
|
+
matches = children_named(current, name)
|
|
140
|
+
if index >= len(matches):
|
|
141
|
+
raise PointerResolutionError(
|
|
142
|
+
f"no {name!r} occurrence {index} under "
|
|
143
|
+
f"{local_name(current.tag)!r}"
|
|
144
|
+
)
|
|
145
|
+
current = matches[index]
|
|
146
|
+
return current
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Country-aware unstructured-to-hybrid address heuristics.
|
|
17
|
+
|
|
18
|
+
:func:`split_unstructured` dispatches free-form address lines to a
|
|
19
|
+
country-specific splitter (``GB``, ``US``, ``DE``, ``FR``, ``JP``) or the
|
|
20
|
+
best-effort fallback, returning a :class:`HeuristicResult` that carries the
|
|
21
|
+
recovered structured fields plus a ``confidence`` score for downstream
|
|
22
|
+
patch operations. Ported from ``pacs008.standards.address.from_unstructured``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable, Sequence
|
|
28
|
+
|
|
29
|
+
from structured_address_fix.adapters.heuristics import (
|
|
30
|
+
de,
|
|
31
|
+
fallback,
|
|
32
|
+
fr,
|
|
33
|
+
gb,
|
|
34
|
+
jp,
|
|
35
|
+
us,
|
|
36
|
+
)
|
|
37
|
+
from structured_address_fix.adapters.heuristics.base import (
|
|
38
|
+
CONFIDENCE_EMPTY,
|
|
39
|
+
HeuristicResult,
|
|
40
|
+
)
|
|
41
|
+
from structured_address_fix.adapters.iso3166 import is_iso_3166_1_alpha_2
|
|
42
|
+
from structured_address_fix.errors import InvalidAddressError
|
|
43
|
+
|
|
44
|
+
_Splitter = Callable[[list[str], str], HeuristicResult]
|
|
45
|
+
|
|
46
|
+
_HANDLERS: dict[str, _Splitter] = {
|
|
47
|
+
"GB": gb.split,
|
|
48
|
+
"US": us.split,
|
|
49
|
+
"DE": de.split,
|
|
50
|
+
"FR": fr.split,
|
|
51
|
+
"JP": jp.split,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def split_unstructured(
|
|
56
|
+
lines: Sequence[str],
|
|
57
|
+
country: str,
|
|
58
|
+
) -> HeuristicResult:
|
|
59
|
+
"""Split unstructured address lines into a country-aware hybrid form.
|
|
60
|
+
|
|
61
|
+
Country-specific heuristics cover ``GB``, ``US``, ``DE``, ``FR`` and
|
|
62
|
+
``JP``; every other country falls back to promoting the last line to
|
|
63
|
+
``town_name``. Empty or whitespace-only input yields a country-only
|
|
64
|
+
result with ``0.0`` confidence.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
lines: Free-form address lines from legacy data. Empty and
|
|
68
|
+
whitespace-only lines are skipped.
|
|
69
|
+
country: The ISO 3166-1 alpha-2 country code (e.g. ``"GB"``).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A :class:`HeuristicResult` carrying the derived structured fields,
|
|
73
|
+
residual ``address_lines``, and a heuristic ``confidence``.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
InvalidAddressError: if ``country`` is not a valid ISO 3166-1
|
|
77
|
+
alpha-2 code.
|
|
78
|
+
"""
|
|
79
|
+
if not is_iso_3166_1_alpha_2(country):
|
|
80
|
+
raise InvalidAddressError(
|
|
81
|
+
"country must be ISO 3166-1 alpha-2 (e.g. 'GB', 'US'); "
|
|
82
|
+
f"got {country!r}",
|
|
83
|
+
context={"country": country},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
cleaned = [line.strip() for line in lines if line and line.strip()]
|
|
87
|
+
if not cleaned:
|
|
88
|
+
return HeuristicResult(country=country, confidence=CONFIDENCE_EMPTY)
|
|
89
|
+
|
|
90
|
+
handler = _HANDLERS.get(country, fallback.split)
|
|
91
|
+
return handler(cleaned, country)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["HeuristicResult", "split_unstructured"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Shared types and helpers for the country-aware address heuristics.
|
|
17
|
+
|
|
18
|
+
Ported from ``pacs008.standards.address``. The behavioural change from the
|
|
19
|
+
original is the return type: rather than a bare ``PostalAddress``, each
|
|
20
|
+
country splitter yields a :class:`HeuristicResult` carrying the derived
|
|
21
|
+
structured fields, the residual free-form ``address_lines``, and a
|
|
22
|
+
``confidence`` score in ``[0, 1]`` that downstream remediation copies onto
|
|
23
|
+
each :class:`~structured_address_fix.domain.remediation.PatchOperation`.
|
|
24
|
+
|
|
25
|
+
Confidence is assigned by anchor quality:
|
|
26
|
+
|
|
27
|
+
- ``0.9`` when a country-specific postcode/state anchor matched, so town
|
|
28
|
+
and postcode were located structurally.
|
|
29
|
+
- ``0.4`` when no anchor matched and the last line was promoted to
|
|
30
|
+
``town_name`` as a best-effort fallback.
|
|
31
|
+
- ``0.0`` when the input carried no usable content at all.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Sequence
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
39
|
+
|
|
40
|
+
from structured_address_fix.domain.address import (
|
|
41
|
+
MAX_ADDRESS_LINE,
|
|
42
|
+
MAX_HYBRID_ADDRESS_LINE_COUNT,
|
|
43
|
+
MAX_TOWN_NAME,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
#: Confidence when a postcode/state anchor located town and postcode.
|
|
47
|
+
CONFIDENCE_ANCHOR: float = 0.9
|
|
48
|
+
|
|
49
|
+
#: Confidence when the last line was promoted to town as a fallback.
|
|
50
|
+
CONFIDENCE_FALLBACK: float = 0.4
|
|
51
|
+
|
|
52
|
+
#: Confidence when the input carried no usable content.
|
|
53
|
+
CONFIDENCE_EMPTY: float = 0.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HeuristicResult(BaseModel):
|
|
57
|
+
"""The structured fields a splitter recovered from free-form lines.
|
|
58
|
+
|
|
59
|
+
Immutable. ``street_name`` is included for completeness — the current
|
|
60
|
+
country splitters do not infer it, so it is always ``None`` for now —
|
|
61
|
+
while ``town_name``, ``post_code`` and ``country_sub_division`` are
|
|
62
|
+
populated when an anchor is found. ``address_lines`` holds the residual
|
|
63
|
+
free-form text, capped at the CBPR+ hybrid limit of two lines.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(frozen=True)
|
|
67
|
+
|
|
68
|
+
town_name: str | None = None
|
|
69
|
+
post_code: str | None = None
|
|
70
|
+
country_sub_division: str | None = None
|
|
71
|
+
street_name: str | None = None
|
|
72
|
+
country: str
|
|
73
|
+
address_lines: tuple[str, ...] = ()
|
|
74
|
+
confidence: float = Field(ge=0.0, le=1.0, default=0.0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clip(value: str | None, maximum: int) -> str | None:
|
|
78
|
+
"""Truncate ``value`` to ``maximum`` characters, preserving ``None``.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
value: The string to clip, or ``None``.
|
|
82
|
+
maximum: The maximum permitted length.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
``None`` if ``value`` is ``None``, else ``value`` truncated to
|
|
86
|
+
``maximum`` characters.
|
|
87
|
+
"""
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
return value[:maximum]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pack_adr_lines(remaining: Sequence[str]) -> tuple[str, ...]:
|
|
94
|
+
"""Pack residual lines into the hybrid ``AdrLine`` cap.
|
|
95
|
+
|
|
96
|
+
Whitespace-only lines are dropped, each surviving line is truncated to
|
|
97
|
+
the 70-character ISO 20022 maximum, and at most two lines are kept to
|
|
98
|
+
respect the CBPR+ UG2026 hybrid cap.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
remaining: The leftover free-form lines.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Up to two cleaned, length-clamped address lines.
|
|
105
|
+
"""
|
|
106
|
+
cleaned = [line.strip() for line in remaining if line and line.strip()]
|
|
107
|
+
return tuple(
|
|
108
|
+
line[:MAX_ADDRESS_LINE]
|
|
109
|
+
for line in cleaned[:MAX_HYBRID_ADDRESS_LINE_COUNT]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def clip_town(value: str | None) -> str | None:
|
|
114
|
+
"""Clip a candidate town name to the ISO 20022 ``TwnNm`` maximum.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
value: The candidate town name, or ``None``.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The clipped town name, or ``None``.
|
|
121
|
+
"""
|
|
122
|
+
return clip(value, MAX_TOWN_NAME)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Shared continental-European ``<5 digits> <town>`` splitter.
|
|
17
|
+
|
|
18
|
+
Germany (``PLZ Ort``) and France (``code postal Ville``) share the same
|
|
19
|
+
line shape: a five-digit postcode immediately followed by the town name.
|
|
20
|
+
The country-specific modules supply their own compiled pattern.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from structured_address_fix.adapters.heuristics.base import (
|
|
28
|
+
CONFIDENCE_ANCHOR,
|
|
29
|
+
CONFIDENCE_FALLBACK,
|
|
30
|
+
HeuristicResult,
|
|
31
|
+
clip,
|
|
32
|
+
clip_town,
|
|
33
|
+
pack_adr_lines,
|
|
34
|
+
)
|
|
35
|
+
from structured_address_fix.domain.address import MAX_POST_CODE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def split_continental(
|
|
39
|
+
lines: list[str],
|
|
40
|
+
country: str,
|
|
41
|
+
pattern: re.Pattern[str],
|
|
42
|
+
) -> HeuristicResult:
|
|
43
|
+
"""Split a continental-European address on a ``<5 digits> <town>`` line.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
lines: Cleaned, non-empty address lines.
|
|
47
|
+
country: The ISO 3166-1 alpha-2 country code.
|
|
48
|
+
pattern: The compiled postcode pattern (capturing postcode then
|
|
49
|
+
town).
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A :class:`HeuristicResult` with ``0.9`` confidence when the
|
|
53
|
+
pattern anchored the split, else ``0.4`` for the last-line
|
|
54
|
+
fallback.
|
|
55
|
+
"""
|
|
56
|
+
pst_cd: str | None = None
|
|
57
|
+
twn_nm: str | None = None
|
|
58
|
+
remaining: list[str] = []
|
|
59
|
+
|
|
60
|
+
for i, line in enumerate(lines):
|
|
61
|
+
match = pattern.search(line)
|
|
62
|
+
if match:
|
|
63
|
+
pst_cd = match.group(1)
|
|
64
|
+
twn_nm = match.group(2).strip()
|
|
65
|
+
remaining = [ln for j, ln in enumerate(lines) if j != i]
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
if pst_cd is None:
|
|
69
|
+
twn_nm = lines[-1]
|
|
70
|
+
remaining = lines[:-1]
|
|
71
|
+
confidence = CONFIDENCE_FALLBACK
|
|
72
|
+
else:
|
|
73
|
+
confidence = CONFIDENCE_ANCHOR
|
|
74
|
+
|
|
75
|
+
return HeuristicResult(
|
|
76
|
+
town_name=clip_town(twn_nm),
|
|
77
|
+
post_code=clip(pst_cd, MAX_POST_CODE),
|
|
78
|
+
country=country,
|
|
79
|
+
address_lines=pack_adr_lines(remaining),
|
|
80
|
+
confidence=confidence,
|
|
81
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Germany address splitter: five-digit PLZ followed by the Ort."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from structured_address_fix.adapters.heuristics.base import HeuristicResult
|
|
23
|
+
from structured_address_fix.adapters.heuristics.continental import (
|
|
24
|
+
split_continental,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_DE_POSTCODE = re.compile(r"\b(\d{5})\s+([^\d]{2,})")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split(lines: list[str], country: str) -> HeuristicResult:
|
|
31
|
+
"""Split a German address on its ``PLZ Ort`` line.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
lines: Cleaned, non-empty address lines.
|
|
35
|
+
country: The ISO 3166-1 alpha-2 country code (``"DE"``).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A :class:`HeuristicResult` for the German address.
|
|
39
|
+
"""
|
|
40
|
+
return split_continental(lines, country, _DE_POSTCODE)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Best-effort splitter for countries without a dedicated heuristic.
|
|
17
|
+
|
|
18
|
+
The last line is promoted to ``town_name`` and everything before it is
|
|
19
|
+
packed into the residual address lines. Confidence is always ``0.4``
|
|
20
|
+
because no postcode anchor is consulted.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from structured_address_fix.adapters.heuristics.base import (
|
|
26
|
+
CONFIDENCE_FALLBACK,
|
|
27
|
+
HeuristicResult,
|
|
28
|
+
clip_town,
|
|
29
|
+
pack_adr_lines,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def split(lines: list[str], country: str) -> HeuristicResult:
|
|
34
|
+
"""Promote the last line to town for an unhandled country.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
lines: Cleaned, non-empty address lines.
|
|
38
|
+
country: The ISO 3166-1 alpha-2 country code.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A :class:`HeuristicResult` with ``0.4`` fallback confidence.
|
|
42
|
+
"""
|
|
43
|
+
twn_nm = lines[-1]
|
|
44
|
+
remaining = lines[:-1]
|
|
45
|
+
return HeuristicResult(
|
|
46
|
+
town_name=clip_town(twn_nm),
|
|
47
|
+
country=country,
|
|
48
|
+
address_lines=pack_adr_lines(remaining),
|
|
49
|
+
confidence=CONFIDENCE_FALLBACK,
|
|
50
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""France address splitter: five-digit code postal followed by the Ville."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from structured_address_fix.adapters.heuristics.base import HeuristicResult
|
|
23
|
+
from structured_address_fix.adapters.heuristics.continental import (
|
|
24
|
+
split_continental,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_FR_POSTCODE = re.compile(r"\b(\d{5})\s+([^\d]{2,})")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split(lines: list[str], country: str) -> HeuristicResult:
|
|
31
|
+
"""Split a French address on its ``code postal Ville`` line.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
lines: Cleaned, non-empty address lines.
|
|
35
|
+
country: The ISO 3166-1 alpha-2 country code (``"FR"``).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A :class:`HeuristicResult` for the French address.
|
|
39
|
+
"""
|
|
40
|
+
return split_continental(lines, country, _FR_POSTCODE)
|