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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ng-postcode
3
- Version: 0.1.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.1.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
- label: str
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[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)
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 _raw(data: Any) -> Any:
195
- return data
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") or "")
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 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:
@@ -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