ng-postcode 0.2.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.2.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.2.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
@@ -135,7 +136,12 @@ class NearbyUnit:
135
136
 
136
137
  def lookup(code: Postcode, level: int = 1) -> Request[Lookup]:
137
138
  """Resolve a postcode. Levels are cumulative from 1 (validity only) to 5, and
138
- 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}")
139
145
  return Request("/v1/lookup", (("code", str(code)), ("level", str(level))), _lookup)
140
146
 
141
147
 
@@ -151,13 +157,19 @@ def autocomplete(partial: str) -> Request[Autocomplete]:
151
157
 
152
158
  def reverse(at: Coordinate, max_distance_m: float | None = None) -> Request[Reverse]:
153
159
  """Find the postcode of the nearest building, within 25 m unless `max_distance_m`
154
- 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
+ """
155
164
  return Request("/v1/search/reverse", _around(at, "max_distance_m", max_distance_m), _reverse)
156
165
 
157
166
 
158
167
  def nearby(at: Coordinate, radius_m: float | None = None) -> Request[tuple[NearbyUnit, ...]]:
159
168
  """List buildings around a point, nearest first, within 300 m unless `radius_m`
160
- says otherwise. Empty when nothing is in range."""
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
+ """
161
173
  return Request("/v1/search/nearby", _around(at, "radius", radius_m), _nearby)
162
174
 
163
175
 
@@ -169,10 +181,14 @@ def decode(request: Request[T], status: int, body: str) -> T | ApiError:
169
181
  return _malformed(status, f"not JSON: {error}")
170
182
  if not isinstance(envelope, dict):
171
183
  return _malformed(status, "expected a JSON object")
172
- failure = _object(envelope, "error")
173
- if failure is not None:
184
+ failure = envelope.get("error")
185
+ if isinstance(failure, dict):
174
186
  code = _text(failure, "code") or "unknown_error"
175
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")
176
192
  data = request.read(envelope.get("data"))
177
193
  return data if data is not None else _malformed(status, "unexpected data")
178
194
 
@@ -184,6 +200,8 @@ def _around(at: Coordinate, key: str, metres: float | None) -> Params:
184
200
 
185
201
  def _number_text(value: float) -> str:
186
202
  number = float(value)
203
+ if not math.isfinite(number):
204
+ raise ValueError(f"expected a finite number, got {value!r}")
187
205
  return str(int(number)) if number.is_integer() else repr(number)
188
206
 
189
207
 
@@ -101,3 +101,15 @@ def test_error_envelopes_and_bad_bodies_become_values() -> None:
101
101
  error = decode(request, status, text)
102
102
  assert isinstance(error, ApiError)
103
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,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