ng-postcode 0.1.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ng-postcode
3
- Version: 0.1.0
3
+ Version: 0.2.1
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.1.0"
7
+ version = "0.2.1"
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"
@@ -8,6 +8,7 @@ offline.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import math
11
12
  from collections.abc import Callable, Mapping
12
13
  from dataclasses import dataclass, field
13
14
  from typing import Any, Generic, TypeVar
@@ -74,12 +75,17 @@ class Lookup:
74
75
  """Level 4. Undocumented, so left as raw JSON."""
75
76
  point_geometry: Any
76
77
  """Level 5. Undocumented, so left as raw JSON."""
78
+ status: str | None = None
79
+ """`valid`, `not_found`, or `invalid` for a malformed code. Sent at every level."""
80
+ verified: bool | None = None
77
81
 
78
82
 
79
83
  @dataclass(frozen=True, slots=True)
80
84
  class Suggestion:
81
85
  code: str
82
- label: str
86
+ """The value of the segment being completed, such as `A03`, not a full prefix."""
87
+ label: str | None
88
+ """Documented by NIPOST but not sent by the live API as of October 2026."""
83
89
 
84
90
 
85
91
  @dataclass(frozen=True, slots=True)
@@ -117,29 +123,54 @@ class Reverse:
117
123
  """Set when nothing is in range."""
118
124
  radius_m: float | None
119
125
  """The radius the API actually applied."""
126
+ depth: str | None = None
127
+ """How deep the match goes, such as `unit`."""
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class NearbyUnit:
132
+ postcode: str
133
+ display: str
134
+ distance_m: float | None
120
135
 
121
136
 
122
137
  def lookup(code: Postcode, level: int = 1) -> Request[Lookup]:
123
138
  """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."""
139
+ the API caps the answer at the level granted to the key.
140
+
141
+ Raises `ValueError` for a level outside 1 to 5.
142
+ """
143
+ if level not in range(1, 6):
144
+ raise ValueError(f"level must be 1 to 5, got {level!r}")
125
145
  return Request("/v1/lookup", (("code", str(code)), ("level", str(level))), _lookup)
126
146
 
127
147
 
128
148
  def autocomplete(partial: str) -> Request[Autocomplete]:
129
- """Suggest completions for a partial postcode such as `EK 01 A`."""
149
+ """Suggest completions for a partial postcode such as `EK 01 A`.
150
+
151
+ Raises `ValueError` for an empty `partial`: the live API never answers one.
152
+ """
153
+ if not partial.strip():
154
+ raise ValueError("partial must not be empty")
130
155
  return Request("/v1/search/autocomplete", (("q", partial),), _autocomplete)
131
156
 
132
157
 
133
158
  def reverse(at: Coordinate, max_distance_m: float | None = None) -> Request[Reverse]:
134
159
  """Find the postcode of the nearest building, within 25 m unless `max_distance_m`
135
- says otherwise. The API clamps it to 250 m."""
160
+ says otherwise. The API clamps it to 250 m.
161
+
162
+ Raises `ValueError` for a coordinate or distance that is not a finite number.
163
+ """
136
164
  return Request("/v1/search/reverse", _around(at, "max_distance_m", max_distance_m), _reverse)
137
165
 
138
166
 
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)
167
+ def nearby(at: Coordinate, radius_m: float | None = None) -> Request[tuple[NearbyUnit, ...]]:
168
+ """List buildings around a point, nearest first, within 300 m unless `radius_m`
169
+ says otherwise. Empty when nothing is in range.
170
+
171
+ Raises `ValueError` for a coordinate or radius that is not a finite number.
172
+ """
173
+ return Request("/v1/search/nearby", _around(at, "radius", radius_m), _nearby)
143
174
 
144
175
 
145
176
  def decode(request: Request[T], status: int, body: str) -> T | ApiError:
@@ -150,10 +181,14 @@ def decode(request: Request[T], status: int, body: str) -> T | ApiError:
150
181
  return _malformed(status, f"not JSON: {error}")
151
182
  if not isinstance(envelope, dict):
152
183
  return _malformed(status, "expected a JSON object")
153
- failure = _object(envelope, "error")
154
- if failure is not None:
184
+ failure = envelope.get("error")
185
+ if isinstance(failure, dict):
155
186
  code = _text(failure, "code") or "unknown_error"
156
187
  return ApiError(status, code, _text(failure, "message") or "")
188
+ if isinstance(failure, str):
189
+ return ApiError(status, "unknown_error", failure)
190
+ if not 200 <= status < 300:
191
+ return _malformed(status, "an error status without an error")
157
192
  data = request.read(envelope.get("data"))
158
193
  return data if data is not None else _malformed(status, "unexpected data")
159
194
 
@@ -165,6 +200,8 @@ def _around(at: Coordinate, key: str, metres: float | None) -> Params:
165
200
 
166
201
  def _number_text(value: float) -> str:
167
202
  number = float(value)
203
+ if not math.isfinite(number):
204
+ raise ValueError(f"expected a finite number, got {value!r}")
168
205
  return str(int(number)) if number.is_integer() else repr(number)
169
206
 
170
207
 
@@ -191,8 +228,18 @@ def _float(value: Any) -> float | None:
191
228
  return float(value) if is_number else None
192
229
 
193
230
 
194
- def _raw(data: Any) -> Any:
195
- return data
231
+ def _nearby(data: Any) -> tuple[NearbyUnit, ...] | None:
232
+ if not isinstance(data, list):
233
+ return None
234
+ return tuple(
235
+ NearbyUnit(
236
+ postcode=_text(item, "postcode") or "",
237
+ display=_text(item, "display") or "",
238
+ distance_m=_number(item, "distance_m"),
239
+ )
240
+ for item in data
241
+ if isinstance(item, dict)
242
+ )
196
243
 
197
244
 
198
245
  def _lookup(data: Any) -> Lookup | None:
@@ -215,6 +262,8 @@ def _lookup(data: Any) -> Lookup | None:
215
262
  building_use_status=_text(data, "building_use_status"),
216
263
  other_building_info=data.get("other_building_info"),
217
264
  point_geometry=data.get("point_geometry"),
265
+ status=_text(data, "status"),
266
+ verified=data["verified"] if isinstance(data.get("verified"), bool) else None,
218
267
  )
219
268
 
220
269
 
@@ -223,7 +272,7 @@ def _autocomplete(data: Any) -> Autocomplete | None:
223
272
  return None
224
273
  items = data.get("suggestions")
225
274
  suggestions = tuple(
226
- Suggestion(code=_text(item, "code") or "", label=_text(item, "label") or "")
275
+ Suggestion(code=_text(item, "code") or "", label=_text(item, "label"))
227
276
  for item in (items if isinstance(items, list) else [])
228
277
  if isinstance(item, dict)
229
278
  )
@@ -255,6 +304,7 @@ def _reverse(data: Any) -> Reverse | None:
255
304
  state=_text(data, "state"),
256
305
  message=_text(data, "message"),
257
306
  radius_m=_number(data, "radius_m"),
307
+ depth=_text(data, "depth"),
258
308
  )
259
309
 
260
310
 
@@ -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 test_decodes_autocomplete_reverse_and_nearby() -> None:
76
- data = {"segment": "lga", "suggestions": [{"code": "EK-01", "label": "ADO EKITI"}]}
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 == "EK-01"
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
- assert decode(nearby(HERE), 200, body({"results": []})) == {"results": []}
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:
@@ -97,3 +101,15 @@ def test_error_envelopes_and_bad_bodies_become_values() -> None:
97
101
  error = decode(request, status, text)
98
102
  assert isinstance(error, ApiError)
99
103
  assert (error.status, error.code) == (status, "malformed_response")
104
+
105
+
106
+ def test_rejects_requests_the_api_cannot_answer() -> None:
107
+ code = Postcode("EK01A03FK01")
108
+ for level in (0, 6):
109
+ with pytest.raises(ValueError, match="level must be 1 to 5"):
110
+ lookup(code, level)
111
+ for bad in (float("nan"), float("inf")):
112
+ with pytest.raises(ValueError, match="finite"):
113
+ reverse(Coordinate(lat=bad, lng=5.2))
114
+ with pytest.raises(ValueError, match="finite"):
115
+ nearby(Coordinate(lat=7.6, lng=5.2), bad)
@@ -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)
@@ -0,0 +1,50 @@
1
+ """Runs the shared cases in `spec/tolerance.json`: bodies the live API may one day send."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import pytest
10
+
11
+ from ng_postcode import Postcode
12
+ from ng_postcode.api import (
13
+ ApiError,
14
+ Coordinate,
15
+ Request,
16
+ autocomplete,
17
+ decode,
18
+ lookup,
19
+ nearby,
20
+ reverse,
21
+ )
22
+
23
+ SPEC = Path(__file__).resolve().parents[2] / "spec" / "tolerance.json"
24
+ CASES: list[dict[str, Any]] = (
25
+ json.loads(SPEC.read_text(encoding="utf-8"))["cases"] if SPEC.exists() else []
26
+ )
27
+ HERE = Coordinate(lat=7.6211, lng=5.2214)
28
+ REQUESTS: dict[str, Request[Any]] = {
29
+ "lookup": lookup(Postcode("FC03B06AG12"), 1),
30
+ "autocomplete": autocomplete("E"),
31
+ "reverse": reverse(HERE),
32
+ "nearby": nearby(HERE),
33
+ }
34
+
35
+ pytestmark = pytest.mark.skipif(
36
+ not CASES, reason="spec/tolerance.json lives in the repository, not the sdist"
37
+ )
38
+
39
+
40
+ @pytest.mark.parametrize("case", CASES, ids=[case["name"] for case in CASES])
41
+ def test_reaches_the_shared_outcome(case: dict[str, Any]) -> None:
42
+ body = case["text"] if "text" in case else json.dumps(case["body"])
43
+ decoded = decode(REQUESTS[case["request"]], case["status"], body)
44
+ if not isinstance(decoded, ApiError):
45
+ found: tuple[str, str | None] = ("ok", None)
46
+ elif decoded.code == "malformed_response":
47
+ found = ("malformed", None)
48
+ else:
49
+ found = ("rejected", decoded.code)
50
+ assert found == (case["outcome"], case.get("code"))
File without changes
File without changes
File without changes