ng-postcode 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.
- ng_postcode/__init__.py +44 -0
- ng_postcode/_postcode.py +235 -0
- ng_postcode/api.py +266 -0
- ng_postcode/client.py +91 -0
- ng_postcode/py.typed +0 -0
- ng_postcode-0.1.0.dist-info/METADATA +97 -0
- ng_postcode-0.1.0.dist-info/RECORD +9 -0
- ng_postcode-0.1.0.dist-info/WHEEL +4 -0
- ng_postcode-0.1.0.dist-info/licenses/LICENSE +21 -0
ng_postcode/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Nigeria's National Digital Alphanumeric Postcode (NDAPS), the building-level
|
|
2
|
+
postcode issued by NIPOST.
|
|
3
|
+
|
|
4
|
+
Expected failures come back as values, never exceptions:
|
|
5
|
+
|
|
6
|
+
>>> from ng_postcode import Postcode, Segment, parse
|
|
7
|
+
>>> code = parse("ek 01 a03 fk 01")
|
|
8
|
+
>>> isinstance(code, Postcode)
|
|
9
|
+
True
|
|
10
|
+
>>> str(code), code.compact, code.spaced
|
|
11
|
+
('EK-01-A03-FK-01', 'EK01A03FK01', 'EK 01 A03 FK 01')
|
|
12
|
+
>>> code.prefix(Segment.AREA)
|
|
13
|
+
'EK-01-A03-FK'
|
|
14
|
+
>>> str(parse("EK-01-A03"))
|
|
15
|
+
'expected 11 letters and digits, found 7'
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._postcode import (
|
|
19
|
+
Corrected,
|
|
20
|
+
InvalidCharacter,
|
|
21
|
+
InvalidSegment,
|
|
22
|
+
ParseError,
|
|
23
|
+
Postcode,
|
|
24
|
+
Segment,
|
|
25
|
+
WrongLength,
|
|
26
|
+
from_segments,
|
|
27
|
+
is_valid,
|
|
28
|
+
parse,
|
|
29
|
+
parse_lenient,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Corrected",
|
|
34
|
+
"InvalidCharacter",
|
|
35
|
+
"InvalidSegment",
|
|
36
|
+
"ParseError",
|
|
37
|
+
"Postcode",
|
|
38
|
+
"Segment",
|
|
39
|
+
"WrongLength",
|
|
40
|
+
"from_segments",
|
|
41
|
+
"is_valid",
|
|
42
|
+
"parse",
|
|
43
|
+
"parse_lenient",
|
|
44
|
+
]
|
ng_postcode/_postcode.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Offline parsing, validation and formatting. Pure: no I/O, no mutation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import string
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TypeAlias
|
|
9
|
+
|
|
10
|
+
LENGTH = 11
|
|
11
|
+
|
|
12
|
+
_LETTERS = frozenset(string.ascii_uppercase)
|
|
13
|
+
_DIGITS = frozenset(string.digits)
|
|
14
|
+
_SEPARATORS = frozenset(" -")
|
|
15
|
+
_TO_LETTER = str.maketrans("0158", "OISB")
|
|
16
|
+
_TO_DIGIT = str.maketrans("OILSB", "01158")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Segment(Enum):
|
|
20
|
+
"""The five segments of a postcode, `AA-99-H77-BB-55`, widest first."""
|
|
21
|
+
|
|
22
|
+
STATE = "state"
|
|
23
|
+
LGA = "lga"
|
|
24
|
+
DISTRICT = "district"
|
|
25
|
+
AREA = "area"
|
|
26
|
+
UNIT = "unit"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_SPANS = {
|
|
30
|
+
Segment.STATE: range(0, 2),
|
|
31
|
+
Segment.LGA: range(2, 4),
|
|
32
|
+
Segment.DISTRICT: range(4, 7),
|
|
33
|
+
Segment.AREA: range(7, 9),
|
|
34
|
+
Segment.UNIT: range(9, 11),
|
|
35
|
+
}
|
|
36
|
+
_ALPHA = frozenset({Segment.STATE, Segment.AREA})
|
|
37
|
+
_NUMERIC = frozenset({Segment.LGA, Segment.UNIT})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class WrongLength:
|
|
42
|
+
"""The input did not hold exactly 11 letters and digits."""
|
|
43
|
+
|
|
44
|
+
found: int
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
return f"expected {LENGTH} letters and digits, found {self.found}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class InvalidCharacter:
|
|
52
|
+
"""The input held something other than letters, digits, spaces and hyphens."""
|
|
53
|
+
|
|
54
|
+
char: str
|
|
55
|
+
index: int
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str:
|
|
58
|
+
return f"invalid character {self.char!r} at index {self.index}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class InvalidSegment:
|
|
63
|
+
"""A segment has the wrong shape, such as digits in the state or `00` as a unit."""
|
|
64
|
+
|
|
65
|
+
segment: Segment
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
return f"invalid {self.segment.value} segment"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ParseError: TypeAlias = WrongLength | InvalidCharacter | InvalidSegment
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
75
|
+
class Postcode:
|
|
76
|
+
"""A well-formed postcode, held in its compact upper-case form.
|
|
77
|
+
|
|
78
|
+
Well formed is not the same as assigned: only the NIPOST API knows whether
|
|
79
|
+
a code belongs to a real building. Build one with `parse`; constructing it
|
|
80
|
+
from an unchecked string raises `ValueError`, as that is a programming error.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
compact: str
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
if _collect(self.compact) != self.compact or _validate(self.compact) is not None:
|
|
87
|
+
raise ValueError(f"not a compact upper-case postcode: {self.compact!r}")
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return self.prefix(Segment.UNIT)
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str:
|
|
93
|
+
return f"Postcode('{self}')"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def spaced(self) -> str:
|
|
97
|
+
"""The form shown to people, `EK 01 A03 FK 01`."""
|
|
98
|
+
return " ".join(self.segment(s) for s in Segment)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def state(self) -> str:
|
|
102
|
+
return self.segment(Segment.STATE)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def lga(self) -> str:
|
|
106
|
+
return self.segment(Segment.LGA)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def district(self) -> str:
|
|
110
|
+
return self.segment(Segment.DISTRICT)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def area(self) -> str:
|
|
114
|
+
return self.segment(Segment.AREA)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def unit(self) -> str:
|
|
118
|
+
return self.segment(Segment.UNIT)
|
|
119
|
+
|
|
120
|
+
def segment(self, segment: Segment) -> str:
|
|
121
|
+
return _part(self.compact, segment)
|
|
122
|
+
|
|
123
|
+
def prefix(self, through: Segment) -> str:
|
|
124
|
+
"""The hyphenated code down to `through`: `prefix(Segment.AREA)` is `EK-01-A03-FK`."""
|
|
125
|
+
order = list(Segment)
|
|
126
|
+
return "-".join(self.segment(s) for s in order[: order.index(through) + 1])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class Corrected:
|
|
131
|
+
"""The result of `parse_lenient`."""
|
|
132
|
+
|
|
133
|
+
postcode: Postcode
|
|
134
|
+
corrections: int
|
|
135
|
+
"""How many characters were swapped for their look-alike."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse(text: str) -> Postcode | ParseError:
|
|
139
|
+
"""Parse a hyphenated, spaced or compact code in either case.
|
|
140
|
+
|
|
141
|
+
>>> parse("ek 01 a03 fk 01")
|
|
142
|
+
Postcode('EK-01-A03-FK-01')
|
|
143
|
+
>>> parse("EK-00-A03-FK-01")
|
|
144
|
+
InvalidSegment(segment=<Segment.LGA: 'lga'>)
|
|
145
|
+
"""
|
|
146
|
+
compact = _collect(text)
|
|
147
|
+
if not isinstance(compact, str):
|
|
148
|
+
return compact
|
|
149
|
+
error = _validate(compact)
|
|
150
|
+
return error if error is not None else Postcode(compact)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_lenient(text: str) -> Corrected | ParseError:
|
|
154
|
+
"""Parse after swapping look-alikes that cannot occur where they stand: `0 1 5 8`
|
|
155
|
+
become `O I S B` where a letter is required, and `O I L S B` become `0 1 1 5 8`
|
|
156
|
+
where a digit is. The district allows both, so it is never rewritten.
|
|
157
|
+
|
|
158
|
+
The result is well formed but may not be the code the user meant, so confirm
|
|
159
|
+
it with them when `corrections` is not zero.
|
|
160
|
+
|
|
161
|
+
>>> parse_lenient("EK-O1-A03-FK-0I")
|
|
162
|
+
Corrected(postcode=Postcode('EK-01-A03-FK-01'), corrections=2)
|
|
163
|
+
"""
|
|
164
|
+
raw = _collect(text)
|
|
165
|
+
if not isinstance(raw, str):
|
|
166
|
+
return raw
|
|
167
|
+
fixed = "".join(_unconfuse(s, _part(raw, s)) for s in Segment)
|
|
168
|
+
error = _validate(fixed)
|
|
169
|
+
if error is not None:
|
|
170
|
+
return error
|
|
171
|
+
return Corrected(Postcode(fixed), sum(a != b for a, b in zip(raw, fixed, strict=True)))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def from_segments(
|
|
175
|
+
state: str, lga: str, district: str, area: str, unit: str
|
|
176
|
+
) -> Postcode | ParseError:
|
|
177
|
+
"""Build a code from its segments, zero-filling the LGA and unit.
|
|
178
|
+
|
|
179
|
+
>>> from_segments("ek", "1", "a03", "fk", "1")
|
|
180
|
+
Postcode('EK-01-A03-FK-01')
|
|
181
|
+
"""
|
|
182
|
+
values = (state, lga, district, area, unit)
|
|
183
|
+
padded = [_padded(s, v) for s, v in zip(Segment, values, strict=True)]
|
|
184
|
+
error = next((p for p in padded if isinstance(p, InvalidSegment)), None)
|
|
185
|
+
if error is not None:
|
|
186
|
+
return error
|
|
187
|
+
return parse("".join(p for p in padded if isinstance(p, str)))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_valid(text: str) -> bool:
|
|
191
|
+
"""Whether `text` is a well-formed postcode."""
|
|
192
|
+
return isinstance(parse(text), Postcode)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _collect(text: str) -> str | ParseError:
|
|
196
|
+
kept = [(index, char) for index, char in enumerate(text) if char not in _SEPARATORS]
|
|
197
|
+
invalid = next(((i, c) for i, c in kept if not (c.isascii() and c.isalnum())), None)
|
|
198
|
+
if invalid is not None:
|
|
199
|
+
return InvalidCharacter(char=invalid[1], index=invalid[0])
|
|
200
|
+
if len(kept) != LENGTH:
|
|
201
|
+
return WrongLength(found=len(kept))
|
|
202
|
+
return "".join(char for _, char in kept).upper()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _part(compact: str, segment: Segment) -> str:
|
|
206
|
+
span = _SPANS[segment]
|
|
207
|
+
return compact[span.start : span.stop]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validate(compact: str) -> InvalidSegment | None:
|
|
211
|
+
return next((InvalidSegment(s) for s in Segment if not _accepts(s, _part(compact, s))), None)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _accepts(segment: Segment, text: str) -> bool:
|
|
215
|
+
chars = set(text)
|
|
216
|
+
if segment in _ALPHA:
|
|
217
|
+
return chars <= _LETTERS
|
|
218
|
+
if segment in _NUMERIC:
|
|
219
|
+
return chars <= _DIGITS and text != "00"
|
|
220
|
+
return chars <= _LETTERS | _DIGITS
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _unconfuse(segment: Segment, text: str) -> str:
|
|
224
|
+
if segment in _ALPHA:
|
|
225
|
+
return text.translate(_TO_LETTER)
|
|
226
|
+
if segment in _NUMERIC:
|
|
227
|
+
return text.translate(_TO_DIGIT)
|
|
228
|
+
return text
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _padded(segment: Segment, value: str) -> str | InvalidSegment:
|
|
232
|
+
text, width = value.strip(), len(_SPANS[segment])
|
|
233
|
+
shortest = 1 if segment in _NUMERIC else width
|
|
234
|
+
fits = shortest <= len(text) <= width and text.isascii() and text.isalnum()
|
|
235
|
+
return text.rjust(width, "0") if fits else InvalidSegment(segment)
|
ng_postcode/api.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The NIPOST Postcode API as plain data: requests to send and responses to decode.
|
|
2
|
+
|
|
3
|
+
Nothing here performs I/O, so it works with any HTTP client, sync or async.
|
|
4
|
+
Assembly and disassembly are not modelled: `parse` and `from_segments` do both
|
|
5
|
+
offline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Generic, TypeVar
|
|
14
|
+
|
|
15
|
+
from ._postcode import Postcode, Segment
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
BASE_URL = "https://api.postcode.gov.ng"
|
|
20
|
+
|
|
21
|
+
Params = tuple[tuple[str, str], ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Request(Generic[T]):
|
|
26
|
+
"""A GET request whose successful response decodes to `T`."""
|
|
27
|
+
|
|
28
|
+
path: str
|
|
29
|
+
params: Params
|
|
30
|
+
read: Callable[[Any], T | None] = field(repr=False, compare=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Coordinate:
|
|
35
|
+
lat: float
|
|
36
|
+
lng: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ApiError:
|
|
41
|
+
"""The API refused the request, with `code` taken from its error envelope
|
|
42
|
+
(`auth_required`, `insufficient_credits`, ...), or its answer was not the
|
|
43
|
+
documented envelope, with `code` set to `malformed_response`."""
|
|
44
|
+
|
|
45
|
+
status: int
|
|
46
|
+
code: str
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"{self.code} ({self.status}): {self.message}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class AdministrativeAddress:
|
|
55
|
+
state_name: str | None
|
|
56
|
+
lga_name: str | None
|
|
57
|
+
locality_name: str | None
|
|
58
|
+
zone: str | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Lookup:
|
|
63
|
+
"""Fields above the level granted to the key are `None`."""
|
|
64
|
+
|
|
65
|
+
postcode: str
|
|
66
|
+
valid: bool
|
|
67
|
+
administrative_address: AdministrativeAddress | None
|
|
68
|
+
"""Level 2."""
|
|
69
|
+
recent_house_address: str | None
|
|
70
|
+
"""Level 2."""
|
|
71
|
+
building_use_status: str | None
|
|
72
|
+
"""Level 3."""
|
|
73
|
+
other_building_info: Any
|
|
74
|
+
"""Level 4. Undocumented, so left as raw JSON."""
|
|
75
|
+
point_geometry: Any
|
|
76
|
+
"""Level 5. Undocumented, so left as raw JSON."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Suggestion:
|
|
81
|
+
code: str
|
|
82
|
+
label: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Autocomplete:
|
|
87
|
+
segment: Segment | None
|
|
88
|
+
"""The segment the suggestions complete."""
|
|
89
|
+
suggestions: tuple[Suggestion, ...]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class NearestUnit:
|
|
94
|
+
postcode: str
|
|
95
|
+
display: str
|
|
96
|
+
distance_m: float | None
|
|
97
|
+
confidence: str | None
|
|
98
|
+
"""`high`, `medium` or `low`, graded by distance."""
|
|
99
|
+
state_name: str | None
|
|
100
|
+
lga_name: str | None
|
|
101
|
+
locality_name: str | None
|
|
102
|
+
address: str | None
|
|
103
|
+
"""Recent house address. This and the names above need level 2."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class Reverse:
|
|
108
|
+
found: bool
|
|
109
|
+
coordinate: Coordinate | None
|
|
110
|
+
"""The queried point, echoed back."""
|
|
111
|
+
unit: NearestUnit | None
|
|
112
|
+
"""The nearest building, absent when nothing is in range."""
|
|
113
|
+
area: str | None
|
|
114
|
+
district: str | None
|
|
115
|
+
state: str | None
|
|
116
|
+
message: str | None
|
|
117
|
+
"""Set when nothing is in range."""
|
|
118
|
+
radius_m: float | None
|
|
119
|
+
"""The radius the API actually applied."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lookup(code: Postcode, level: int = 1) -> Request[Lookup]:
|
|
123
|
+
"""Resolve a postcode. Levels are cumulative from 1 (validity only) to 5, and
|
|
124
|
+
the API caps the answer at the level granted to the key."""
|
|
125
|
+
return Request("/v1/lookup", (("code", str(code)), ("level", str(level))), _lookup)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def autocomplete(partial: str) -> Request[Autocomplete]:
|
|
129
|
+
"""Suggest completions for a partial postcode such as `EK 01 A`."""
|
|
130
|
+
return Request("/v1/search/autocomplete", (("q", partial),), _autocomplete)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def reverse(at: Coordinate, max_distance_m: float | None = None) -> Request[Reverse]:
|
|
134
|
+
"""Find the postcode of the nearest building, within 25 m unless `max_distance_m`
|
|
135
|
+
says otherwise. The API clamps it to 250 m."""
|
|
136
|
+
return Request("/v1/search/reverse", _around(at, "max_distance_m", max_distance_m), _reverse)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def nearby(at: Coordinate, radius_m: float | None = None) -> Request[Any]:
|
|
140
|
+
"""List buildings around a point, within 300 m unless `radius_m` says otherwise.
|
|
141
|
+
The API does not document the response, so it stays raw JSON."""
|
|
142
|
+
return Request("/v1/search/nearby", _around(at, "radius", radius_m), _raw)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def decode(request: Request[T], status: int, body: str) -> T | ApiError:
|
|
146
|
+
"""Decode the response to `request` from its status and body."""
|
|
147
|
+
try:
|
|
148
|
+
envelope = json.loads(body)
|
|
149
|
+
except ValueError as error:
|
|
150
|
+
return _malformed(status, f"not JSON: {error}")
|
|
151
|
+
if not isinstance(envelope, dict):
|
|
152
|
+
return _malformed(status, "expected a JSON object")
|
|
153
|
+
failure = _object(envelope, "error")
|
|
154
|
+
if failure is not None:
|
|
155
|
+
code = _text(failure, "code") or "unknown_error"
|
|
156
|
+
return ApiError(status, code, _text(failure, "message") or "")
|
|
157
|
+
data = request.read(envelope.get("data"))
|
|
158
|
+
return data if data is not None else _malformed(status, "unexpected data")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _around(at: Coordinate, key: str, metres: float | None) -> Params:
|
|
162
|
+
point = (("lat", _number_text(at.lat)), ("lng", _number_text(at.lng)))
|
|
163
|
+
return point if metres is None else (*point, (key, _number_text(metres)))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _number_text(value: float) -> str:
|
|
167
|
+
number = float(value)
|
|
168
|
+
return str(int(number)) if number.is_integer() else repr(number)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _malformed(status: int, message: str) -> ApiError:
|
|
172
|
+
return ApiError(status, "malformed_response", message)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _object(data: Mapping[str, Any], key: str) -> Mapping[str, Any] | None:
|
|
176
|
+
value = data.get(key)
|
|
177
|
+
return value if isinstance(value, dict) else None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _text(data: Mapping[str, Any], key: str) -> str | None:
|
|
181
|
+
value = data.get(key)
|
|
182
|
+
return value if isinstance(value, str) else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _number(data: Mapping[str, Any], key: str) -> float | None:
|
|
186
|
+
return _float(data.get(key))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _float(value: Any) -> float | None:
|
|
190
|
+
is_number = isinstance(value, int | float) and not isinstance(value, bool)
|
|
191
|
+
return float(value) if is_number else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _raw(data: Any) -> Any:
|
|
195
|
+
return data
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _lookup(data: Any) -> Lookup | None:
|
|
199
|
+
if not isinstance(data, dict):
|
|
200
|
+
return None
|
|
201
|
+
admin = _object(data, "administrative_address")
|
|
202
|
+
recent = _object(data, "recent_house_address")
|
|
203
|
+
return Lookup(
|
|
204
|
+
postcode=_text(data, "postcode") or "",
|
|
205
|
+
valid=data.get("valid") is True,
|
|
206
|
+
administrative_address=None
|
|
207
|
+
if admin is None
|
|
208
|
+
else AdministrativeAddress(
|
|
209
|
+
state_name=_text(admin, "state_name"),
|
|
210
|
+
lga_name=_text(admin, "lga_name"),
|
|
211
|
+
locality_name=_text(admin, "locality_name"),
|
|
212
|
+
zone=_text(admin, "zone"),
|
|
213
|
+
),
|
|
214
|
+
recent_house_address=None if recent is None else _text(recent, "recent"),
|
|
215
|
+
building_use_status=_text(data, "building_use_status"),
|
|
216
|
+
other_building_info=data.get("other_building_info"),
|
|
217
|
+
point_geometry=data.get("point_geometry"),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _autocomplete(data: Any) -> Autocomplete | None:
|
|
222
|
+
if not isinstance(data, dict):
|
|
223
|
+
return None
|
|
224
|
+
items = data.get("suggestions")
|
|
225
|
+
suggestions = tuple(
|
|
226
|
+
Suggestion(code=_text(item, "code") or "", label=_text(item, "label") or "")
|
|
227
|
+
for item in (items if isinstance(items, list) else [])
|
|
228
|
+
if isinstance(item, dict)
|
|
229
|
+
)
|
|
230
|
+
segment = next((s for s in Segment if s.value == data.get("segment")), None)
|
|
231
|
+
return Autocomplete(segment=segment, suggestions=suggestions)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _reverse(data: Any) -> Reverse | None:
|
|
235
|
+
if not isinstance(data, dict):
|
|
236
|
+
return None
|
|
237
|
+
unit = _object(data, "unit")
|
|
238
|
+
return Reverse(
|
|
239
|
+
found=data.get("found") is True,
|
|
240
|
+
coordinate=_coordinate(data.get("coordinate")),
|
|
241
|
+
unit=None
|
|
242
|
+
if unit is None
|
|
243
|
+
else NearestUnit(
|
|
244
|
+
postcode=_text(unit, "postcode") or "",
|
|
245
|
+
display=_text(unit, "display") or "",
|
|
246
|
+
distance_m=_number(unit, "distance_m"),
|
|
247
|
+
confidence=_text(unit, "confidence"),
|
|
248
|
+
state_name=_text(unit, "state_name"),
|
|
249
|
+
lga_name=_text(unit, "lga_name"),
|
|
250
|
+
locality_name=_text(unit, "locality_name"),
|
|
251
|
+
address=_text(unit, "address"),
|
|
252
|
+
),
|
|
253
|
+
area=_text(data, "area"),
|
|
254
|
+
district=_text(data, "district"),
|
|
255
|
+
state=_text(data, "state"),
|
|
256
|
+
message=_text(data, "message"),
|
|
257
|
+
radius_m=_number(data, "radius_m"),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _coordinate(value: Any) -> Coordinate | None:
|
|
262
|
+
"""The API echoes points as `[lng, lat]`."""
|
|
263
|
+
if not isinstance(value, list) or len(value) != 2:
|
|
264
|
+
return None
|
|
265
|
+
lng, lat = (_float(v) for v in value)
|
|
266
|
+
return None if lng is None or lat is None else Coordinate(lat=lat, lng=lng)
|
ng_postcode/client.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HTTP shell over `ng_postcode.api`, built on httpx: the only module that performs I/O.
|
|
2
|
+
|
|
3
|
+
Install with `pip install "ng-postcode[client]"`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .api import BASE_URL, ApiError, Request, decode
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
TIMEOUT = 10.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class TransportError:
|
|
22
|
+
"""The request never produced a response: DNS, TLS, timeout and the like."""
|
|
23
|
+
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return self.reason
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Client:
|
|
31
|
+
"""Blocking client. Pass `http` to reuse your own `httpx.Client`; it stays yours to close."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, api_key: str, *, base_url: str = BASE_URL, http: httpx.Client | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
self._http = http if http is not None else httpx.Client(timeout=TIMEOUT)
|
|
37
|
+
self._owns_http = http is None
|
|
38
|
+
self._base_url = base_url
|
|
39
|
+
self._headers = {"X-API-Key": api_key}
|
|
40
|
+
|
|
41
|
+
def send(self, request: Request[T]) -> T | ApiError | TransportError:
|
|
42
|
+
url = self._base_url + request.path
|
|
43
|
+
try:
|
|
44
|
+
response = self._http.get(url, params=request.params, headers=self._headers)
|
|
45
|
+
except httpx.HTTPError as error:
|
|
46
|
+
return _transport(error)
|
|
47
|
+
return decode(request, response.status_code, response.text)
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
if self._owns_http:
|
|
51
|
+
self._http.close()
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> Client:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
57
|
+
self.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AsyncClient:
|
|
61
|
+
"""Async client. Pass `http` to reuse your own `httpx.AsyncClient`; it stays yours to close."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self, api_key: str, *, base_url: str = BASE_URL, http: httpx.AsyncClient | None = None
|
|
65
|
+
) -> None:
|
|
66
|
+
self._http = http if http is not None else httpx.AsyncClient(timeout=TIMEOUT)
|
|
67
|
+
self._owns_http = http is None
|
|
68
|
+
self._base_url = base_url
|
|
69
|
+
self._headers = {"X-API-Key": api_key}
|
|
70
|
+
|
|
71
|
+
async def send(self, request: Request[T]) -> T | ApiError | TransportError:
|
|
72
|
+
url = self._base_url + request.path
|
|
73
|
+
try:
|
|
74
|
+
response = await self._http.get(url, params=request.params, headers=self._headers)
|
|
75
|
+
except httpx.HTTPError as error:
|
|
76
|
+
return _transport(error)
|
|
77
|
+
return decode(request, response.status_code, response.text)
|
|
78
|
+
|
|
79
|
+
async def aclose(self) -> None:
|
|
80
|
+
if self._owns_http:
|
|
81
|
+
await self._http.aclose()
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> AsyncClient:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
87
|
+
await self.aclose()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _transport(error: httpx.HTTPError) -> TransportError:
|
|
91
|
+
return TransportError(str(error) or type(error).__name__)
|
ng_postcode/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ng-postcode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parse, validate and format Nigeria's NIPOST digital postcode (NDAPS) offline, plus a client for the postcode.gov.ng API.
|
|
5
|
+
Project-URL: Repository, https://github.com/Adeniyikayodee/ng-postcode
|
|
6
|
+
Project-URL: Issues, https://github.com/Adeniyikayodee/ng-postcode/issues
|
|
7
|
+
Project-URL: API docs, https://docs.postcode.gov.ng
|
|
8
|
+
Author: Kayode Adeniyi
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: address,address-validation,geocoding,ndaps,nigeria,nipost,postal-code,postcode
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Provides-Extra: client
|
|
27
|
+
Requires-Dist: httpx>=0.27; extra == 'client'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# ng-postcode
|
|
31
|
+
|
|
32
|
+
Python library for Nigeria's National Digital Alphanumeric Postcode System (NDAPS), the building-level postcode NIPOST launched in October 2026. Parse, validate and format postcodes offline, and call the [postcode.gov.ng](https://docs.postcode.gov.ng) API for lookup, autocomplete and reverse geocoding.
|
|
33
|
+
|
|
34
|
+
Also available for Rust: [`ng-postcode` on crates.io](https://crates.io/crates/ng-postcode).
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install ng-postcode # offline core, no dependencies
|
|
38
|
+
pip install "ng-postcode[client]" # adds the API client (httpx)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Format
|
|
42
|
+
|
|
43
|
+
An 11-character code in five segments: state, LGA, district, area, building unit.
|
|
44
|
+
|
|
45
|
+
| Style | Example |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Canonical | `EK-01-A03-FK-01` |
|
|
48
|
+
| Display | `EK 01 A03 FK 01` |
|
|
49
|
+
| Compact | `EK01A03FK01` |
|
|
50
|
+
|
|
51
|
+
Compact form as a regular expression: `^[A-Z]{2}(0[1-9]|[1-9][0-9])[A-Z0-9]{3}[A-Z]{2}(0[1-9]|[1-9][0-9])$`
|
|
52
|
+
|
|
53
|
+
## Offline
|
|
54
|
+
|
|
55
|
+
Expected failures are returned as values, not raised, so the type checker makes you handle them.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from ng_postcode import Postcode, Segment, parse
|
|
59
|
+
|
|
60
|
+
match parse("ek 01 a03 fk 01"):
|
|
61
|
+
case Postcode() as code:
|
|
62
|
+
print(code) # EK-01-A03-FK-01
|
|
63
|
+
print(code.compact) # EK01A03FK01, store this
|
|
64
|
+
print(code.spaced) # EK 01 A03 FK 01
|
|
65
|
+
print(code.prefix(Segment.AREA)) # EK-01-A03-FK
|
|
66
|
+
case error:
|
|
67
|
+
print(error) # e.g. "invalid lga segment"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `parse` accepts hyphenated, spaced or compact input in either case.
|
|
71
|
+
- `parse_lenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
|
|
72
|
+
- `from_segments` assembles a code from its parts and zero-fills the LGA and unit.
|
|
73
|
+
- `Postcode` is immutable, hashable and sorts by state, LGA, district, area, unit.
|
|
74
|
+
|
|
75
|
+
A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from ng_postcode import Postcode, parse
|
|
81
|
+
from ng_postcode.api import lookup
|
|
82
|
+
from ng_postcode.client import Client
|
|
83
|
+
|
|
84
|
+
code = parse("EK-01-A03-FK-01")
|
|
85
|
+
assert isinstance(code, Postcode)
|
|
86
|
+
|
|
87
|
+
with Client(api_key="nipost_live_...") as client:
|
|
88
|
+
found = client.send(lookup(code, level=2))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`send` returns the typed response, an `ApiError` (for example `auth_required` or `insufficient_credits`) or a `TransportError`. `AsyncClient` has the same interface for asyncio.
|
|
92
|
+
|
|
93
|
+
`ng_postcode.api` covers lookup, autocomplete, reverse geocoding and nearby search. Each function returns a `Request` value and `decode` turns a status and body into a typed result, so it works with any HTTP client without the `client` extra.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ng_postcode/__init__.py,sha256=SPlQ7P6HF35AztumNfYW1iY0iiX3gInn_W9Y4lPrN7Y,930
|
|
2
|
+
ng_postcode/_postcode.py,sha256=fgMsN0_r04_Nzz9tqPhnXxI0bxbUQGAFBPcFnO1TAQ8,7184
|
|
3
|
+
ng_postcode/api.py,sha256=0_8oi6fDmCa2p5wpf67eCYkQd2mxMFUcKaXVRxfQN10,8596
|
|
4
|
+
ng_postcode/client.py,sha256=CrLHgq47N_m09CcvpttCRfmrfRb2I-tG4gaxXDyckn4,2799
|
|
5
|
+
ng_postcode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ng_postcode-0.1.0.dist-info/METADATA,sha256=myuOFZlUpcDqZBWlUI03BfjhLb2l0dqCTG5JFTb0Ni4,3927
|
|
7
|
+
ng_postcode-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
ng_postcode-0.1.0.dist-info/licenses/LICENSE,sha256=5J4ejW4oekCqBi05kyohGmFDQp7wWDwN4vP5w_R7lGM,1071
|
|
9
|
+
ng_postcode-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kayode Adeniyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|