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.
Files changed (49) hide show
  1. structured_address_fix/__init__.py +60 -0
  2. structured_address_fix/adapters/__init__.py +58 -0
  3. structured_address_fix/adapters/_xmlutil.py +146 -0
  4. structured_address_fix/adapters/heuristics/__init__.py +94 -0
  5. structured_address_fix/adapters/heuristics/base.py +122 -0
  6. structured_address_fix/adapters/heuristics/continental.py +81 -0
  7. structured_address_fix/adapters/heuristics/de.py +40 -0
  8. structured_address_fix/adapters/heuristics/fallback.py +50 -0
  9. structured_address_fix/adapters/heuristics/fr.py +40 -0
  10. structured_address_fix/adapters/heuristics/gb.py +90 -0
  11. structured_address_fix/adapters/heuristics/jp.py +88 -0
  12. structured_address_fix/adapters/heuristics/us.py +101 -0
  13. structured_address_fix/adapters/iso3166.py +140 -0
  14. structured_address_fix/adapters/xml_reader.py +207 -0
  15. structured_address_fix/adapters/xml_writer.py +244 -0
  16. structured_address_fix/adapters/xpath.py +114 -0
  17. structured_address_fix/config.py +48 -0
  18. structured_address_fix/data/iso3166_alpha2.json +251 -0
  19. structured_address_fix/data/rulebook_clauses/cbpr-2026.json +9 -0
  20. structured_address_fix/data/rulebook_clauses/generic-structured.json +5 -0
  21. structured_address_fix/data/rulebook_clauses/hvps-plus.json +9 -0
  22. structured_address_fix/data/rulebook_clauses/sepa.json +7 -0
  23. structured_address_fix/domain/__init__.py +53 -0
  24. structured_address_fix/domain/address.py +168 -0
  25. structured_address_fix/domain/enums.py +87 -0
  26. structured_address_fix/domain/findings.py +82 -0
  27. structured_address_fix/domain/party.py +40 -0
  28. structured_address_fix/domain/remediation.py +77 -0
  29. structured_address_fix/domain/result.py +57 -0
  30. structured_address_fix/errors.py +138 -0
  31. structured_address_fix/plugins/__init__.py +79 -0
  32. structured_address_fix/plugins/licensing.py +101 -0
  33. structured_address_fix/policies/__init__.py +55 -0
  34. structured_address_fix/policies/base.py +409 -0
  35. structured_address_fix/policies/cbpr_2026.py +108 -0
  36. structured_address_fix/policies/generic_structured.py +67 -0
  37. structured_address_fix/policies/hvps_plus.py +105 -0
  38. structured_address_fix/policies/registry.py +153 -0
  39. structured_address_fix/policies/sepa.py +80 -0
  40. structured_address_fix/services/__init__.py +39 -0
  41. structured_address_fix/services/apply_patch.py +41 -0
  42. structured_address_fix/services/assess.py +105 -0
  43. structured_address_fix/services/classify.py +56 -0
  44. structured_address_fix/services/facade.py +209 -0
  45. structured_address_fix/services/remediate.py +196 -0
  46. structured_address_fix-0.1.0.dist-info/METADATA +404 -0
  47. structured_address_fix-0.1.0.dist-info/RECORD +49 -0
  48. structured_address_fix-0.1.0.dist-info/WHEEL +4 -0
  49. 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)