nodii-address 0.1.0__tar.gz
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,51 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
build/
|
|
4
|
+
coverage/
|
|
5
|
+
.DS_Store
|
|
6
|
+
*.log
|
|
7
|
+
.env
|
|
8
|
+
.env.local
|
|
9
|
+
.env.*.local
|
|
10
|
+
.turbo/
|
|
11
|
+
.bun/
|
|
12
|
+
|
|
13
|
+
# AI review loop artifacts (scripts/review/ai-review.ts)
|
|
14
|
+
.claude-pre-review.md
|
|
15
|
+
.claude-pre-review.json
|
|
16
|
+
.claude/pending-patches/
|
|
17
|
+
|
|
18
|
+
# Agent session state (not committed; per-session)
|
|
19
|
+
.claude/endpoints-snapshot.json
|
|
20
|
+
.agent-todo.md
|
|
21
|
+
|
|
22
|
+
# Python (uv)
|
|
23
|
+
.venv/
|
|
24
|
+
__pycache__/
|
|
25
|
+
*.pyc
|
|
26
|
+
*.pyo
|
|
27
|
+
*.egg-info/
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.ruff_cache/
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
htmlcov/
|
|
32
|
+
.coverage
|
|
33
|
+
.coverage.*
|
|
34
|
+
coverage.xml
|
|
35
|
+
|
|
36
|
+
# Go
|
|
37
|
+
*.test
|
|
38
|
+
*.out
|
|
39
|
+
go.work.sum
|
|
40
|
+
|
|
41
|
+
# Generated proto artifacts (regenerate via `bun proto:gen`)
|
|
42
|
+
ts/_generated/
|
|
43
|
+
python/_generated/
|
|
44
|
+
go/_generated/
|
|
45
|
+
|
|
46
|
+
# Test-stack volumes (docker-compose persistent data)
|
|
47
|
+
infra/test-stack/data/
|
|
48
|
+
# synthetic consumer go binaries
|
|
49
|
+
synthetic-consumers/telemetry/go/nodii-synthetic-telemetry
|
|
50
|
+
synthetic-consumers/pii/go/nodii-synthetic-pii
|
|
51
|
+
synthetic-consumers/auth-sdk/go/nodii-synthetic-auth-sdk
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodii-address
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nodii canonical postal Address value-object + validator (ISO-3166-1 alpha-2; AddressInvalid).
|
|
5
|
+
Project-URL: Homepage, https://github.com/cognion-nucleus/nodii-libs
|
|
6
|
+
Project-URL: Repository, https://github.com/cognion-nucleus/nodii-libs
|
|
7
|
+
Author-email: Nodii <ops@nodii.co>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: address,nodii
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.11
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# nodii-address
|
|
2
|
+
|
|
3
|
+
The canonical postal **Address** value-object + validator for the Nodii stack.
|
|
4
|
+
A pure value-object library (no I/O): a dataclass, a validator, and PII-field
|
|
5
|
+
tagging. Geocoding / distributor resolution is **not** here — that stays in
|
|
6
|
+
nodii-geo-service.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from nodii_address import (
|
|
10
|
+
validate_address,
|
|
11
|
+
is_valid_address,
|
|
12
|
+
AddressInvalid,
|
|
13
|
+
ADDRESS_PII_FIELDS,
|
|
14
|
+
Address,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
addr = validate_address(
|
|
18
|
+
{"line1": "221B Baker St", "city": "London", "country_code": "gb"}
|
|
19
|
+
)
|
|
20
|
+
# → Address(line1="221B Baker St", city="London", country_code="GB", ...)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
validate_address({"line1": "x", "city": "y", "country_code": "ZZ"})
|
|
24
|
+
except AddressInvalid as e:
|
|
25
|
+
e.code # "ADDRESS_INVALID" → map to CUSTOMER_ADDRESS_INVALID
|
|
26
|
+
e.reason # "COUNTRY_CODE_INVALID"
|
|
27
|
+
e.field # "country_code"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Shape
|
|
31
|
+
|
|
32
|
+
`{label?, line1, line2?, city, region?, postal_code?, country_code}` — matches
|
|
33
|
+
the gRPC `Address` message. `country_code` is ISO-3166-1 alpha-2 (validated
|
|
34
|
+
against the assigned set). Required: `line1`, `city`, `country_code`.
|
|
35
|
+
|
|
36
|
+
## Validation
|
|
37
|
+
|
|
38
|
+
`validate_address(data)` trims string fields, upper-cases `country_code`, drops
|
|
39
|
+
empty optionals (→ `None`), and raises `AddressInvalid` (`.code`, `.reason`,
|
|
40
|
+
`.field`) on a missing required field or an invalid country code.
|
|
41
|
+
`is_valid_address` is the non-raising variant. Per-country postal/region format
|
|
42
|
+
rules are intentionally out of scope (geo-service owns those).
|
|
43
|
+
|
|
44
|
+
## PII
|
|
45
|
+
|
|
46
|
+
`ADDRESS_PII_FIELDS` lists the fields that are PII (`line1`, `line2`, `city`,
|
|
47
|
+
`region`, `postal_code`) and must be encrypted under the owning subject's DEK at
|
|
48
|
+
rest by the consumer (via `nodii-pii`). `label` and `country_code` are not PII.
|
|
49
|
+
This library never stores or encrypts.
|
|
50
|
+
|
|
51
|
+
Shipped in parity with `@nodii/address` (TS) and `nodii.co/nodii-libs/go/address`.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# nodii-address — the canonical postal Address value-object + validator.
|
|
2
|
+
|
|
3
|
+
[project]
|
|
4
|
+
name = "nodii-address"
|
|
5
|
+
version = "0.1.0"
|
|
6
|
+
description = "Nodii canonical postal Address value-object + validator (ISO-3166-1 alpha-2; AddressInvalid)."
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Nodii", email = "ops@nodii.co" },
|
|
11
|
+
]
|
|
12
|
+
keywords = ["nodii", "address"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/cognion-nucleus/nodii-libs"
|
|
23
|
+
Repository = "https://github.com/cognion-nucleus/nodii-libs"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/nodii_address"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = ["src/", "tests/", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"ruff>=0.7",
|
|
38
|
+
"mypy>=1.13",
|
|
39
|
+
"pytest>=8.3",
|
|
40
|
+
"pytest-asyncio>=0.24",
|
|
41
|
+
"pytest-cov>=6.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 100
|
|
46
|
+
target-version = "py311"
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = [
|
|
50
|
+
"E", # pycodestyle errors
|
|
51
|
+
"W", # pycodestyle warnings
|
|
52
|
+
"F", # pyflakes
|
|
53
|
+
"I", # isort
|
|
54
|
+
"B", # flake8-bugbear
|
|
55
|
+
"C4", # flake8-comprehensions
|
|
56
|
+
"UP", # pyupgrade
|
|
57
|
+
"N", # pep8-naming
|
|
58
|
+
"ASYNC", # flake8-async
|
|
59
|
+
"S", # flake8-bandit (security)
|
|
60
|
+
"PT", # flake8-pytest-style
|
|
61
|
+
"RET", # flake8-return
|
|
62
|
+
"SIM", # flake8-simplify
|
|
63
|
+
]
|
|
64
|
+
ignore = [
|
|
65
|
+
"S101", # use of assert detected — fine in tests
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint.per-file-ignores]
|
|
69
|
+
"tests/**/*.py" = ["S101", "S105", "S106"] # bandit relaxed for tests
|
|
70
|
+
|
|
71
|
+
[tool.ruff.format]
|
|
72
|
+
quote-style = "double"
|
|
73
|
+
indent-style = "space"
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
strict = true
|
|
77
|
+
python_version = "3.11"
|
|
78
|
+
warn_redundant_casts = true
|
|
79
|
+
warn_unused_ignores = true
|
|
80
|
+
warn_unreachable = true
|
|
81
|
+
disallow_untyped_decorators = true
|
|
82
|
+
|
|
83
|
+
[tool.pytest.ini_options]
|
|
84
|
+
testpaths = ["tests"]
|
|
85
|
+
asyncio_mode = "auto"
|
|
86
|
+
addopts = [
|
|
87
|
+
"-ra",
|
|
88
|
+
"--strict-markers",
|
|
89
|
+
"--strict-config",
|
|
90
|
+
"--cov=src",
|
|
91
|
+
"--cov-report=term-missing",
|
|
92
|
+
"--cov-report=html",
|
|
93
|
+
"--cov-report=xml",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[tool.coverage.run]
|
|
97
|
+
source = ["src"]
|
|
98
|
+
branch = true
|
|
99
|
+
|
|
100
|
+
[tool.coverage.report]
|
|
101
|
+
fail_under = 80
|
|
102
|
+
show_missing = true
|
|
103
|
+
exclude_lines = [
|
|
104
|
+
"pragma: no cover",
|
|
105
|
+
"raise NotImplementedError",
|
|
106
|
+
"if TYPE_CHECKING:",
|
|
107
|
+
"if __name__ == \"__main__\":",
|
|
108
|
+
]
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""nodii-address — the canonical postal Address value-object + validator.
|
|
2
|
+
|
|
3
|
+
A pure value-object library (NO I/O): an ``Address`` dataclass, a
|
|
4
|
+
``validate_address`` validator surfacing a typed ``AddressInvalid`` (mapped by
|
|
5
|
+
consumers to CUSTOMER_ADDRESS_INVALID), and the PII-field tagging. Geocoding /
|
|
6
|
+
distributor resolution is explicitly NOT here — it stays in nodii-geo-service
|
|
7
|
+
(``geo.DistributionResolver``). Request 5235eb58 (nodii-customer-service).
|
|
8
|
+
|
|
9
|
+
PII posture: line1/line2/city/region/postal_code are PII under the OWNING
|
|
10
|
+
subject's DEK (the consuming service encrypts via nodii-pii); ``label`` is a
|
|
11
|
+
non-PII tag and ``country_code`` is non-PII. This lib only TAGS which fields are
|
|
12
|
+
PII (see ``ADDRESS_PII_FIELDS``) — it never stores or encrypts.
|
|
13
|
+
|
|
14
|
+
Cross-lang parity with @nodii/address (TS) + go/address.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
__version__ = "0.0.1"
|
|
24
|
+
|
|
25
|
+
LIB_NAME = "address"
|
|
26
|
+
|
|
27
|
+
#: Fields of :class:`Address` that carry PII and MUST be encrypted under the
|
|
28
|
+
#: owning subject's DEK at rest. ``label`` and ``country_code`` are NOT PII.
|
|
29
|
+
ADDRESS_PII_FIELDS: tuple[str, ...] = (
|
|
30
|
+
"line1",
|
|
31
|
+
"line2",
|
|
32
|
+
"city",
|
|
33
|
+
"region",
|
|
34
|
+
"postal_code",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
AddressInvalidReason = Literal[
|
|
38
|
+
"LINE1_REQUIRED",
|
|
39
|
+
"CITY_REQUIRED",
|
|
40
|
+
"COUNTRY_CODE_REQUIRED",
|
|
41
|
+
"COUNTRY_CODE_INVALID",
|
|
42
|
+
"NOT_AN_OBJECT",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Address:
|
|
48
|
+
"""Canonical postal address (gRPC ``Address`` message shape).
|
|
49
|
+
|
|
50
|
+
Required: ``line1``, ``city``, ``country_code``. Optional: ``label``,
|
|
51
|
+
``line2``, ``region``, ``postal_code``. ``country_code`` is ISO-3166-1
|
|
52
|
+
alpha-2 (uppercase).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
line1: str
|
|
56
|
+
city: str
|
|
57
|
+
country_code: str
|
|
58
|
+
label: str | None = None
|
|
59
|
+
line2: str | None = None
|
|
60
|
+
region: str | None = None
|
|
61
|
+
postal_code: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AddressInvalid(ValueError): # noqa: N818 — contract-specified name (spec 5235eb58 + TS/Go parity); not "AddressInvalidError"
|
|
65
|
+
"""Raised by :func:`validate_address`. Consumers map ``.code`` (stable
|
|
66
|
+
string) to their transport status — for nodii-customer-service that is
|
|
67
|
+
``CUSTOMER_ADDRESS_INVALID``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
code = "ADDRESS_INVALID"
|
|
71
|
+
|
|
72
|
+
def __init__(self, reason: AddressInvalidReason, field: str | None = None) -> None:
|
|
73
|
+
self.reason: AddressInvalidReason = reason
|
|
74
|
+
self.field = field
|
|
75
|
+
msg = (
|
|
76
|
+
f'address invalid: {reason} (field "{field}")'
|
|
77
|
+
if field
|
|
78
|
+
else f"address invalid: {reason}"
|
|
79
|
+
)
|
|
80
|
+
super().__init__(msg)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ISO-3166-1 alpha-2 — the official assigned set (249 codes), byte-identical to
|
|
84
|
+
# the TS + Go ports so validation is cross-lang identical. Sorted; user-assigned
|
|
85
|
+
# ranges (e.g. XK) excluded.
|
|
86
|
+
ISO_3166_1_ALPHA2: frozenset[str] = frozenset(
|
|
87
|
+
[
|
|
88
|
+
"AD",
|
|
89
|
+
"AE",
|
|
90
|
+
"AF",
|
|
91
|
+
"AG",
|
|
92
|
+
"AI",
|
|
93
|
+
"AL",
|
|
94
|
+
"AM",
|
|
95
|
+
"AO",
|
|
96
|
+
"AQ",
|
|
97
|
+
"AR",
|
|
98
|
+
"AS",
|
|
99
|
+
"AT",
|
|
100
|
+
"AU",
|
|
101
|
+
"AW",
|
|
102
|
+
"AX",
|
|
103
|
+
"AZ",
|
|
104
|
+
"BA",
|
|
105
|
+
"BB",
|
|
106
|
+
"BD",
|
|
107
|
+
"BE",
|
|
108
|
+
"BF",
|
|
109
|
+
"BG",
|
|
110
|
+
"BH",
|
|
111
|
+
"BI",
|
|
112
|
+
"BJ",
|
|
113
|
+
"BL",
|
|
114
|
+
"BM",
|
|
115
|
+
"BN",
|
|
116
|
+
"BO",
|
|
117
|
+
"BQ",
|
|
118
|
+
"BR",
|
|
119
|
+
"BS",
|
|
120
|
+
"BT",
|
|
121
|
+
"BV",
|
|
122
|
+
"BW",
|
|
123
|
+
"BY",
|
|
124
|
+
"BZ",
|
|
125
|
+
"CA",
|
|
126
|
+
"CC",
|
|
127
|
+
"CD",
|
|
128
|
+
"CF",
|
|
129
|
+
"CG",
|
|
130
|
+
"CH",
|
|
131
|
+
"CI",
|
|
132
|
+
"CK",
|
|
133
|
+
"CL",
|
|
134
|
+
"CM",
|
|
135
|
+
"CN",
|
|
136
|
+
"CO",
|
|
137
|
+
"CR",
|
|
138
|
+
"CU",
|
|
139
|
+
"CV",
|
|
140
|
+
"CW",
|
|
141
|
+
"CX",
|
|
142
|
+
"CY",
|
|
143
|
+
"CZ",
|
|
144
|
+
"DE",
|
|
145
|
+
"DJ",
|
|
146
|
+
"DK",
|
|
147
|
+
"DM",
|
|
148
|
+
"DO",
|
|
149
|
+
"DZ",
|
|
150
|
+
"EC",
|
|
151
|
+
"EE",
|
|
152
|
+
"EG",
|
|
153
|
+
"EH",
|
|
154
|
+
"ER",
|
|
155
|
+
"ES",
|
|
156
|
+
"ET",
|
|
157
|
+
"FI",
|
|
158
|
+
"FJ",
|
|
159
|
+
"FK",
|
|
160
|
+
"FM",
|
|
161
|
+
"FO",
|
|
162
|
+
"FR",
|
|
163
|
+
"GA",
|
|
164
|
+
"GB",
|
|
165
|
+
"GD",
|
|
166
|
+
"GE",
|
|
167
|
+
"GF",
|
|
168
|
+
"GG",
|
|
169
|
+
"GH",
|
|
170
|
+
"GI",
|
|
171
|
+
"GL",
|
|
172
|
+
"GM",
|
|
173
|
+
"GN",
|
|
174
|
+
"GP",
|
|
175
|
+
"GQ",
|
|
176
|
+
"GR",
|
|
177
|
+
"GS",
|
|
178
|
+
"GT",
|
|
179
|
+
"GU",
|
|
180
|
+
"GW",
|
|
181
|
+
"GY",
|
|
182
|
+
"HK",
|
|
183
|
+
"HM",
|
|
184
|
+
"HN",
|
|
185
|
+
"HR",
|
|
186
|
+
"HT",
|
|
187
|
+
"HU",
|
|
188
|
+
"ID",
|
|
189
|
+
"IE",
|
|
190
|
+
"IL",
|
|
191
|
+
"IM",
|
|
192
|
+
"IN",
|
|
193
|
+
"IO",
|
|
194
|
+
"IQ",
|
|
195
|
+
"IR",
|
|
196
|
+
"IS",
|
|
197
|
+
"IT",
|
|
198
|
+
"JE",
|
|
199
|
+
"JM",
|
|
200
|
+
"JO",
|
|
201
|
+
"JP",
|
|
202
|
+
"KE",
|
|
203
|
+
"KG",
|
|
204
|
+
"KH",
|
|
205
|
+
"KI",
|
|
206
|
+
"KM",
|
|
207
|
+
"KN",
|
|
208
|
+
"KP",
|
|
209
|
+
"KR",
|
|
210
|
+
"KW",
|
|
211
|
+
"KY",
|
|
212
|
+
"KZ",
|
|
213
|
+
"LA",
|
|
214
|
+
"LB",
|
|
215
|
+
"LC",
|
|
216
|
+
"LI",
|
|
217
|
+
"LK",
|
|
218
|
+
"LR",
|
|
219
|
+
"LS",
|
|
220
|
+
"LT",
|
|
221
|
+
"LU",
|
|
222
|
+
"LV",
|
|
223
|
+
"LY",
|
|
224
|
+
"MA",
|
|
225
|
+
"MC",
|
|
226
|
+
"MD",
|
|
227
|
+
"ME",
|
|
228
|
+
"MF",
|
|
229
|
+
"MG",
|
|
230
|
+
"MH",
|
|
231
|
+
"MK",
|
|
232
|
+
"ML",
|
|
233
|
+
"MM",
|
|
234
|
+
"MN",
|
|
235
|
+
"MO",
|
|
236
|
+
"MP",
|
|
237
|
+
"MQ",
|
|
238
|
+
"MR",
|
|
239
|
+
"MS",
|
|
240
|
+
"MT",
|
|
241
|
+
"MU",
|
|
242
|
+
"MV",
|
|
243
|
+
"MW",
|
|
244
|
+
"MX",
|
|
245
|
+
"MY",
|
|
246
|
+
"MZ",
|
|
247
|
+
"NA",
|
|
248
|
+
"NC",
|
|
249
|
+
"NE",
|
|
250
|
+
"NF",
|
|
251
|
+
"NG",
|
|
252
|
+
"NI",
|
|
253
|
+
"NL",
|
|
254
|
+
"NO",
|
|
255
|
+
"NP",
|
|
256
|
+
"NR",
|
|
257
|
+
"NU",
|
|
258
|
+
"NZ",
|
|
259
|
+
"OM",
|
|
260
|
+
"PA",
|
|
261
|
+
"PE",
|
|
262
|
+
"PF",
|
|
263
|
+
"PG",
|
|
264
|
+
"PH",
|
|
265
|
+
"PK",
|
|
266
|
+
"PL",
|
|
267
|
+
"PM",
|
|
268
|
+
"PN",
|
|
269
|
+
"PR",
|
|
270
|
+
"PS",
|
|
271
|
+
"PT",
|
|
272
|
+
"PW",
|
|
273
|
+
"PY",
|
|
274
|
+
"QA",
|
|
275
|
+
"RE",
|
|
276
|
+
"RO",
|
|
277
|
+
"RS",
|
|
278
|
+
"RU",
|
|
279
|
+
"RW",
|
|
280
|
+
"SA",
|
|
281
|
+
"SB",
|
|
282
|
+
"SC",
|
|
283
|
+
"SD",
|
|
284
|
+
"SE",
|
|
285
|
+
"SG",
|
|
286
|
+
"SH",
|
|
287
|
+
"SI",
|
|
288
|
+
"SJ",
|
|
289
|
+
"SK",
|
|
290
|
+
"SL",
|
|
291
|
+
"SM",
|
|
292
|
+
"SN",
|
|
293
|
+
"SO",
|
|
294
|
+
"SR",
|
|
295
|
+
"SS",
|
|
296
|
+
"ST",
|
|
297
|
+
"SV",
|
|
298
|
+
"SX",
|
|
299
|
+
"SY",
|
|
300
|
+
"SZ",
|
|
301
|
+
"TC",
|
|
302
|
+
"TD",
|
|
303
|
+
"TF",
|
|
304
|
+
"TG",
|
|
305
|
+
"TH",
|
|
306
|
+
"TJ",
|
|
307
|
+
"TK",
|
|
308
|
+
"TL",
|
|
309
|
+
"TM",
|
|
310
|
+
"TN",
|
|
311
|
+
"TO",
|
|
312
|
+
"TR",
|
|
313
|
+
"TT",
|
|
314
|
+
"TV",
|
|
315
|
+
"TW",
|
|
316
|
+
"TZ",
|
|
317
|
+
"UA",
|
|
318
|
+
"UG",
|
|
319
|
+
"UM",
|
|
320
|
+
"US",
|
|
321
|
+
"UY",
|
|
322
|
+
"UZ",
|
|
323
|
+
"VA",
|
|
324
|
+
"VC",
|
|
325
|
+
"VE",
|
|
326
|
+
"VG",
|
|
327
|
+
"VI",
|
|
328
|
+
"VN",
|
|
329
|
+
"VU",
|
|
330
|
+
"WF",
|
|
331
|
+
"WS",
|
|
332
|
+
"YE",
|
|
333
|
+
"YT",
|
|
334
|
+
"ZA",
|
|
335
|
+
"ZM",
|
|
336
|
+
"ZW",
|
|
337
|
+
] # noqa: SIM905 — split-string keeps the 249-code set byte-identical to TS/Go
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def is_valid_country_code(code: str) -> bool:
|
|
342
|
+
"""``True`` iff ``code`` is an assigned ISO-3166-1 alpha-2 code (uppercase)."""
|
|
343
|
+
return code in ISO_3166_1_ALPHA2
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _trim_or_empty(v: Any) -> str:
|
|
347
|
+
return v.strip() if isinstance(v, str) else ""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _opt_trim(v: Any) -> str | None:
|
|
351
|
+
s = _trim_or_empty(v)
|
|
352
|
+
return s or None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def validate_address(data: Mapping[str, Any]) -> Address:
|
|
356
|
+
"""Validate + normalize an address. Returns a clean :class:`Address` (string
|
|
357
|
+
fields trimmed; empty optionals dropped; ``country_code`` upper-cased) or
|
|
358
|
+
raises :class:`AddressInvalid`.
|
|
359
|
+
|
|
360
|
+
Rules: ``line1``, ``city``, ``country_code`` required + non-empty;
|
|
361
|
+
``country_code`` must be a valid ISO-3166-1 alpha-2 code. Per-country
|
|
362
|
+
postal/region format rules are intentionally OUT of scope (geo-service).
|
|
363
|
+
"""
|
|
364
|
+
if not isinstance(data, Mapping):
|
|
365
|
+
raise AddressInvalid("NOT_AN_OBJECT")
|
|
366
|
+
line1 = _trim_or_empty(data.get("line1"))
|
|
367
|
+
if line1 == "":
|
|
368
|
+
raise AddressInvalid("LINE1_REQUIRED", "line1")
|
|
369
|
+
city = _trim_or_empty(data.get("city"))
|
|
370
|
+
if city == "":
|
|
371
|
+
raise AddressInvalid("CITY_REQUIRED", "city")
|
|
372
|
+
cc_raw = _trim_or_empty(data.get("country_code"))
|
|
373
|
+
if cc_raw == "":
|
|
374
|
+
raise AddressInvalid("COUNTRY_CODE_REQUIRED", "country_code")
|
|
375
|
+
country_code = cc_raw.upper()
|
|
376
|
+
if not is_valid_country_code(country_code):
|
|
377
|
+
raise AddressInvalid("COUNTRY_CODE_INVALID", "country_code")
|
|
378
|
+
return Address(
|
|
379
|
+
line1=line1,
|
|
380
|
+
city=city,
|
|
381
|
+
country_code=country_code,
|
|
382
|
+
label=_opt_trim(data.get("label")),
|
|
383
|
+
line2=_opt_trim(data.get("line2")),
|
|
384
|
+
region=_opt_trim(data.get("region")),
|
|
385
|
+
postal_code=_opt_trim(data.get("postal_code")),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def is_valid_address(data: Mapping[str, Any]) -> bool:
|
|
390
|
+
"""Non-throwing variant: ``True`` iff ``data`` validates."""
|
|
391
|
+
try:
|
|
392
|
+
validate_address(data)
|
|
393
|
+
return True
|
|
394
|
+
except AddressInvalid:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
__all__ = [
|
|
399
|
+
"ADDRESS_PII_FIELDS",
|
|
400
|
+
"ISO_3166_1_ALPHA2",
|
|
401
|
+
"LIB_NAME",
|
|
402
|
+
"Address",
|
|
403
|
+
"AddressInvalid",
|
|
404
|
+
"AddressInvalidReason",
|
|
405
|
+
"__version__",
|
|
406
|
+
"is_valid_address",
|
|
407
|
+
"is_valid_country_code",
|
|
408
|
+
"validate_address",
|
|
409
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""nodii-address validator tests (parity with @nodii/address TS + go/address)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from nodii_address import (
|
|
6
|
+
ADDRESS_PII_FIELDS,
|
|
7
|
+
Address,
|
|
8
|
+
AddressInvalid,
|
|
9
|
+
is_valid_address,
|
|
10
|
+
is_valid_country_code,
|
|
11
|
+
validate_address,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_full_address_trims_and_uppercases_country() -> None:
|
|
16
|
+
a = validate_address(
|
|
17
|
+
{
|
|
18
|
+
"label": " home ",
|
|
19
|
+
"line1": " 221B Baker St ",
|
|
20
|
+
"line2": " Flat 2 ",
|
|
21
|
+
"city": " London ",
|
|
22
|
+
"region": " Greater London ",
|
|
23
|
+
"postal_code": " NW1 6XE ",
|
|
24
|
+
"country_code": "gb",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
assert a == Address(
|
|
28
|
+
line1="221B Baker St",
|
|
29
|
+
city="London",
|
|
30
|
+
country_code="GB",
|
|
31
|
+
label="home",
|
|
32
|
+
line2="Flat 2",
|
|
33
|
+
region="Greater London",
|
|
34
|
+
postal_code="NW1 6XE",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_minimal_address_empty_optionals_become_none() -> None:
|
|
39
|
+
a = validate_address(
|
|
40
|
+
{"line1": "1 Main St", "city": "Bengaluru", "country_code": "IN", "region": " "}
|
|
41
|
+
)
|
|
42
|
+
assert a.line1 == "1 Main St"
|
|
43
|
+
assert a.city == "Bengaluru"
|
|
44
|
+
assert a.country_code == "IN"
|
|
45
|
+
assert a.region is None
|
|
46
|
+
assert a.postal_code is None
|
|
47
|
+
assert a.label is None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize(
|
|
51
|
+
("data", "reason", "field"),
|
|
52
|
+
[
|
|
53
|
+
({"city": "X", "country_code": "US"}, "LINE1_REQUIRED", "line1"),
|
|
54
|
+
({"line1": "L", "country_code": "US"}, "CITY_REQUIRED", "city"),
|
|
55
|
+
({"line1": "L", "city": "X"}, "COUNTRY_CODE_REQUIRED", "country_code"),
|
|
56
|
+
(
|
|
57
|
+
{"line1": "L", "city": "X", "country_code": "ZZ"},
|
|
58
|
+
"COUNTRY_CODE_INVALID",
|
|
59
|
+
"country_code",
|
|
60
|
+
),
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
def test_address_invalid_reasons(data: dict, reason: str, field: str) -> None:
|
|
64
|
+
with pytest.raises(AddressInvalid) as exc:
|
|
65
|
+
validate_address(data)
|
|
66
|
+
assert exc.value.reason == reason
|
|
67
|
+
assert exc.value.field == field
|
|
68
|
+
assert exc.value.code == "ADDRESS_INVALID"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_not_an_object() -> None:
|
|
72
|
+
for bad in (None, 42, "str", ["a"]):
|
|
73
|
+
with pytest.raises(AddressInvalid) as exc:
|
|
74
|
+
validate_address(bad) # type: ignore[arg-type]
|
|
75
|
+
assert exc.value.reason == "NOT_AN_OBJECT"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_is_valid_country_code() -> None:
|
|
79
|
+
assert is_valid_country_code("US") is True
|
|
80
|
+
assert is_valid_country_code("IN") is True
|
|
81
|
+
assert is_valid_country_code("GB") is True
|
|
82
|
+
assert is_valid_country_code("ZZ") is False
|
|
83
|
+
assert is_valid_country_code("us") is False # lowercase not in the set
|
|
84
|
+
assert is_valid_country_code("USA") is False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_is_valid_address() -> None:
|
|
88
|
+
assert is_valid_address({"line1": "L", "city": "C", "country_code": "FR"}) is True
|
|
89
|
+
assert is_valid_address({"line1": "L", "city": "C", "country_code": "XX"}) is False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_pii_fields_tag() -> None:
|
|
93
|
+
assert ADDRESS_PII_FIELDS == ("line1", "line2", "city", "region", "postal_code")
|
|
94
|
+
assert "label" not in ADDRESS_PII_FIELDS
|
|
95
|
+
assert "country_code" not in ADDRESS_PII_FIELDS
|