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.
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/PKG-INFO +1 -1
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/pyproject.toml +1 -1
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/src/ng_postcode/api.py +23 -5
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/tests/test_api.py +12 -0
- ng_postcode-0.2.1/tests/test_tolerance.py +50 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/.gitignore +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/LICENSE +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/README.md +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/src/ng_postcode/__init__.py +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/src/ng_postcode/_postcode.py +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/src/ng_postcode/client.py +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/src/ng_postcode/py.typed +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/tests/test_client.py +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/tests/test_conformance.py +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/tests/test_live_responses.py +0 -0
- {ng_postcode-0.2.0 → ng_postcode-0.2.1}/tests/test_postcode.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ng-postcode
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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 =
|
|
173
|
-
if failure
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|