ng-postcode 0.1.0__tar.gz → 0.2.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.
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/PKG-INFO +1 -1
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/pyproject.toml +1 -1
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/src/ng_postcode/api.py +41 -9
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/tests/test_api.py +9 -5
- ng_postcode-0.2.0/tests/test_live_responses.py +116 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/.gitignore +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/LICENSE +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/README.md +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/src/ng_postcode/__init__.py +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/src/ng_postcode/_postcode.py +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/src/ng_postcode/client.py +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/src/ng_postcode/py.typed +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/tests/test_client.py +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/tests/test_conformance.py +0 -0
- {ng_postcode-0.1.0 → ng_postcode-0.2.0}/tests/test_postcode.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ng-postcode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Parse, validate and format Nigeria's NIPOST digital postcode (NDAPS) offline, plus a client for the postcode.gov.ng API.
|
|
5
5
|
Project-URL: Repository, https://github.com/Adeniyikayodee/ng-postcode
|
|
6
6
|
Project-URL: Issues, https://github.com/Adeniyikayodee/ng-postcode/issues
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ng-postcode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Parse, validate and format Nigeria's NIPOST digital postcode (NDAPS) offline, plus a client for the postcode.gov.ng API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -74,12 +74,17 @@ class Lookup:
|
|
|
74
74
|
"""Level 4. Undocumented, so left as raw JSON."""
|
|
75
75
|
point_geometry: Any
|
|
76
76
|
"""Level 5. Undocumented, so left as raw JSON."""
|
|
77
|
+
status: str | None = None
|
|
78
|
+
"""`valid`, `not_found`, or `invalid` for a malformed code. Sent at every level."""
|
|
79
|
+
verified: bool | None = None
|
|
77
80
|
|
|
78
81
|
|
|
79
82
|
@dataclass(frozen=True, slots=True)
|
|
80
83
|
class Suggestion:
|
|
81
84
|
code: str
|
|
82
|
-
|
|
85
|
+
"""The value of the segment being completed, such as `A03`, not a full prefix."""
|
|
86
|
+
label: str | None
|
|
87
|
+
"""Documented by NIPOST but not sent by the live API as of October 2026."""
|
|
83
88
|
|
|
84
89
|
|
|
85
90
|
@dataclass(frozen=True, slots=True)
|
|
@@ -117,6 +122,15 @@ class Reverse:
|
|
|
117
122
|
"""Set when nothing is in range."""
|
|
118
123
|
radius_m: float | None
|
|
119
124
|
"""The radius the API actually applied."""
|
|
125
|
+
depth: str | None = None
|
|
126
|
+
"""How deep the match goes, such as `unit`."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class NearbyUnit:
|
|
131
|
+
postcode: str
|
|
132
|
+
display: str
|
|
133
|
+
distance_m: float | None
|
|
120
134
|
|
|
121
135
|
|
|
122
136
|
def lookup(code: Postcode, level: int = 1) -> Request[Lookup]:
|
|
@@ -126,7 +140,12 @@ def lookup(code: Postcode, level: int = 1) -> Request[Lookup]:
|
|
|
126
140
|
|
|
127
141
|
|
|
128
142
|
def autocomplete(partial: str) -> Request[Autocomplete]:
|
|
129
|
-
"""Suggest completions for a partial postcode such as `EK 01 A`.
|
|
143
|
+
"""Suggest completions for a partial postcode such as `EK 01 A`.
|
|
144
|
+
|
|
145
|
+
Raises `ValueError` for an empty `partial`: the live API never answers one.
|
|
146
|
+
"""
|
|
147
|
+
if not partial.strip():
|
|
148
|
+
raise ValueError("partial must not be empty")
|
|
130
149
|
return Request("/v1/search/autocomplete", (("q", partial),), _autocomplete)
|
|
131
150
|
|
|
132
151
|
|
|
@@ -136,10 +155,10 @@ def reverse(at: Coordinate, max_distance_m: float | None = None) -> Request[Reve
|
|
|
136
155
|
return Request("/v1/search/reverse", _around(at, "max_distance_m", max_distance_m), _reverse)
|
|
137
156
|
|
|
138
157
|
|
|
139
|
-
def nearby(at: Coordinate, radius_m: float | None = None) -> Request[
|
|
140
|
-
"""List buildings around a point, within 300 m unless `radius_m`
|
|
141
|
-
|
|
142
|
-
return Request("/v1/search/nearby", _around(at, "radius", radius_m),
|
|
158
|
+
def nearby(at: Coordinate, radius_m: float | None = None) -> Request[tuple[NearbyUnit, ...]]:
|
|
159
|
+
"""List buildings around a point, nearest first, within 300 m unless `radius_m`
|
|
160
|
+
says otherwise. Empty when nothing is in range."""
|
|
161
|
+
return Request("/v1/search/nearby", _around(at, "radius", radius_m), _nearby)
|
|
143
162
|
|
|
144
163
|
|
|
145
164
|
def decode(request: Request[T], status: int, body: str) -> T | ApiError:
|
|
@@ -191,8 +210,18 @@ def _float(value: Any) -> float | None:
|
|
|
191
210
|
return float(value) if is_number else None
|
|
192
211
|
|
|
193
212
|
|
|
194
|
-
def
|
|
195
|
-
|
|
213
|
+
def _nearby(data: Any) -> tuple[NearbyUnit, ...] | None:
|
|
214
|
+
if not isinstance(data, list):
|
|
215
|
+
return None
|
|
216
|
+
return tuple(
|
|
217
|
+
NearbyUnit(
|
|
218
|
+
postcode=_text(item, "postcode") or "",
|
|
219
|
+
display=_text(item, "display") or "",
|
|
220
|
+
distance_m=_number(item, "distance_m"),
|
|
221
|
+
)
|
|
222
|
+
for item in data
|
|
223
|
+
if isinstance(item, dict)
|
|
224
|
+
)
|
|
196
225
|
|
|
197
226
|
|
|
198
227
|
def _lookup(data: Any) -> Lookup | None:
|
|
@@ -215,6 +244,8 @@ def _lookup(data: Any) -> Lookup | None:
|
|
|
215
244
|
building_use_status=_text(data, "building_use_status"),
|
|
216
245
|
other_building_info=data.get("other_building_info"),
|
|
217
246
|
point_geometry=data.get("point_geometry"),
|
|
247
|
+
status=_text(data, "status"),
|
|
248
|
+
verified=data["verified"] if isinstance(data.get("verified"), bool) else None,
|
|
218
249
|
)
|
|
219
250
|
|
|
220
251
|
|
|
@@ -223,7 +254,7 @@ def _autocomplete(data: Any) -> Autocomplete | None:
|
|
|
223
254
|
return None
|
|
224
255
|
items = data.get("suggestions")
|
|
225
256
|
suggestions = tuple(
|
|
226
|
-
Suggestion(code=_text(item, "code") or "", label=_text(item, "label")
|
|
257
|
+
Suggestion(code=_text(item, "code") or "", label=_text(item, "label"))
|
|
227
258
|
for item in (items if isinstance(items, list) else [])
|
|
228
259
|
if isinstance(item, dict)
|
|
229
260
|
)
|
|
@@ -255,6 +286,7 @@ def _reverse(data: Any) -> Reverse | None:
|
|
|
255
286
|
state=_text(data, "state"),
|
|
256
287
|
message=_text(data, "message"),
|
|
257
288
|
radius_m=_number(data, "radius_m"),
|
|
289
|
+
depth=_text(data, "depth"),
|
|
258
290
|
)
|
|
259
291
|
|
|
260
292
|
|
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
5
7
|
from ng_postcode import Postcode, Segment, parse
|
|
6
8
|
from ng_postcode.api import (
|
|
7
9
|
AdministrativeAddress,
|
|
@@ -72,20 +74,22 @@ def test_fields_above_the_granted_level_are_none() -> None:
|
|
|
72
74
|
assert (found.administrative_address, found.recent_house_address) == (None, None)
|
|
73
75
|
|
|
74
76
|
|
|
75
|
-
def
|
|
76
|
-
data = {"segment": "lga", "suggestions": [{"code": "
|
|
77
|
+
def test_decodes_documented_labels_and_echoed_coordinates() -> None:
|
|
78
|
+
data = {"segment": "lga", "suggestions": [{"code": "01", "label": "ADO EKITI"}]}
|
|
77
79
|
found = decode(autocomplete("EK"), 200, body(data))
|
|
78
80
|
assert not isinstance(found, ApiError)
|
|
79
81
|
assert found.segment is Segment.LGA
|
|
80
|
-
assert found.suggestions[0].code == "
|
|
82
|
+
assert (found.suggestions[0].code, found.suggestions[0].label) == ("01", "ADO EKITI")
|
|
81
83
|
|
|
82
84
|
miss = {"found": False, "coordinate": [5.22, 7.62], "message": "none", "radius_m": 25}
|
|
83
85
|
result = decode(reverse(HERE), 200, body(miss))
|
|
84
86
|
assert not isinstance(result, ApiError)
|
|
85
|
-
assert (result.found, result.unit, result.radius_m) == (False, None, 25.0)
|
|
86
87
|
assert result.coordinate == HERE
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
|
|
90
|
+
def test_an_empty_autocomplete_is_refused_before_it_hangs_the_api() -> None:
|
|
91
|
+
with pytest.raises(ValueError, match="must not be empty"):
|
|
92
|
+
autocomplete(" ")
|
|
89
93
|
|
|
90
94
|
|
|
91
95
|
def test_error_envelopes_and_bad_bodies_become_values() -> None:
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Decodes the response bodies captured from the live API in `spec/responses.json`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from ng_postcode import Postcode, Segment
|
|
12
|
+
from ng_postcode.api import (
|
|
13
|
+
ApiError,
|
|
14
|
+
Coordinate,
|
|
15
|
+
NearbyUnit,
|
|
16
|
+
Request,
|
|
17
|
+
autocomplete,
|
|
18
|
+
decode,
|
|
19
|
+
lookup,
|
|
20
|
+
nearby,
|
|
21
|
+
reverse,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
RESPONSES = Path(__file__).resolve().parents[2] / "spec" / "responses.json"
|
|
27
|
+
LIVE: dict[str, Any] = (
|
|
28
|
+
json.loads(RESPONSES.read_text(encoding="utf-8")) if RESPONSES.exists() else {}
|
|
29
|
+
)
|
|
30
|
+
CODE = Postcode("FC03B06AG12")
|
|
31
|
+
HERE = Coordinate(lat=7.6211, lng=5.2214)
|
|
32
|
+
|
|
33
|
+
pytestmark = pytest.mark.skipif(
|
|
34
|
+
not LIVE, reason="spec/responses.json lives in the repository, not the sdist"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def live(request: Request[T], name: str) -> T | ApiError:
|
|
39
|
+
captured = LIVE[name]
|
|
40
|
+
return decode(request, captured["status"], json.dumps(captured["body"]))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_lookup_statuses() -> None:
|
|
44
|
+
valid = live(lookup(CODE), "lookup_valid")
|
|
45
|
+
assert not isinstance(valid, ApiError)
|
|
46
|
+
assert (valid.valid, valid.status, valid.verified) == (True, "valid", False)
|
|
47
|
+
assert valid.administrative_address is None
|
|
48
|
+
|
|
49
|
+
missing = live(lookup(CODE), "lookup_not_found")
|
|
50
|
+
assert not isinstance(missing, ApiError)
|
|
51
|
+
assert (missing.valid, missing.status) == (False, "not_found")
|
|
52
|
+
|
|
53
|
+
malformed = live(lookup(CODE), "lookup_invalid")
|
|
54
|
+
assert not isinstance(malformed, ApiError)
|
|
55
|
+
assert (malformed.valid, malformed.status) == (False, "invalid")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_autocomplete_sends_segment_values_without_labels() -> None:
|
|
59
|
+
states = live(autocomplete("E"), "autocomplete_state")
|
|
60
|
+
assert not isinstance(states, ApiError)
|
|
61
|
+
assert states.segment is Segment.STATE
|
|
62
|
+
assert [(s.code, s.label) for s in states.suggestions] == [
|
|
63
|
+
("EB", None),
|
|
64
|
+
("ED", None),
|
|
65
|
+
("EK", None),
|
|
66
|
+
("EN", None),
|
|
67
|
+
]
|
|
68
|
+
units = live(autocomplete("EK 01 A29 KR 3"), "autocomplete_unit_empty")
|
|
69
|
+
assert not isinstance(units, ApiError)
|
|
70
|
+
assert (units.segment, units.suggestions) == (Segment.UNIT, ())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_reverse() -> None:
|
|
74
|
+
found = live(reverse(HERE), "reverse_found")
|
|
75
|
+
assert not isinstance(found, ApiError)
|
|
76
|
+
assert found.unit is not None
|
|
77
|
+
assert (found.unit.postcode, found.unit.distance_m, found.unit.confidence) == (
|
|
78
|
+
"EK-01-A29-KR-36",
|
|
79
|
+
15.7,
|
|
80
|
+
"high",
|
|
81
|
+
)
|
|
82
|
+
assert (found.area, found.district, found.state, found.depth) == (
|
|
83
|
+
"EK-01-A29-KR",
|
|
84
|
+
"EK-01-A29",
|
|
85
|
+
"EK",
|
|
86
|
+
"unit",
|
|
87
|
+
)
|
|
88
|
+
assert found.coordinate == HERE
|
|
89
|
+
assert (found.unit.address, found.radius_m) == (None, 25.0)
|
|
90
|
+
|
|
91
|
+
nothing = live(reverse(HERE), "reverse_not_found")
|
|
92
|
+
assert not isinstance(nothing, ApiError)
|
|
93
|
+
assert (nothing.found, nothing.unit) == (False, None)
|
|
94
|
+
assert nothing.message == "no postcode within range of this location"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_nearby_is_a_list_nearest_first() -> None:
|
|
98
|
+
units = live(nearby(HERE), "nearby_found")
|
|
99
|
+
assert not isinstance(units, ApiError)
|
|
100
|
+
assert units[0] == NearbyUnit("EK-01-A29-KR-36", "EK 01 A29 KR 36", 15.7)
|
|
101
|
+
assert [u.distance_m for u in units] == [15.7, 18.3, 31.0]
|
|
102
|
+
assert live(nearby(HERE), "nearby_empty") == ()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize(
|
|
106
|
+
("name", "code"),
|
|
107
|
+
[
|
|
108
|
+
("lookup_level_not_granted", "level_not_granted"),
|
|
109
|
+
("reverse_bad_request", "bad_request"),
|
|
110
|
+
("invalid_api_key", "invalid_api_key"),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
def test_errors(name: str, code: str) -> None:
|
|
114
|
+
error = live(lookup(CODE), name)
|
|
115
|
+
assert isinstance(error, ApiError)
|
|
116
|
+
assert (error.status, error.code) == (LIVE[name]["status"], code)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|