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.
@@ -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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.