ng-postcode 0.1.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/.gitignore +8 -0
- ng_postcode-0.1.0/LICENSE +21 -0
- ng_postcode-0.1.0/PKG-INFO +97 -0
- ng_postcode-0.1.0/README.md +68 -0
- ng_postcode-0.1.0/pyproject.toml +59 -0
- ng_postcode-0.1.0/src/ng_postcode/__init__.py +44 -0
- ng_postcode-0.1.0/src/ng_postcode/_postcode.py +235 -0
- ng_postcode-0.1.0/src/ng_postcode/api.py +266 -0
- ng_postcode-0.1.0/src/ng_postcode/client.py +91 -0
- ng_postcode-0.1.0/src/ng_postcode/py.typed +0 -0
- ng_postcode-0.1.0/tests/test_api.py +99 -0
- ng_postcode-0.1.0/tests/test_client.py +64 -0
- ng_postcode-0.1.0/tests/test_conformance.py +80 -0
- ng_postcode-0.1.0/tests/test_postcode.py +73 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kayode Adeniyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ng-postcode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parse, validate and format Nigeria's NIPOST digital postcode (NDAPS) offline, plus a client for the postcode.gov.ng API.
|
|
5
|
+
Project-URL: Repository, https://github.com/Adeniyikayodee/ng-postcode
|
|
6
|
+
Project-URL: Issues, https://github.com/Adeniyikayodee/ng-postcode/issues
|
|
7
|
+
Project-URL: API docs, https://docs.postcode.gov.ng
|
|
8
|
+
Author: Kayode Adeniyi
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: address,address-validation,geocoding,ndaps,nigeria,nipost,postal-code,postcode
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Provides-Extra: client
|
|
27
|
+
Requires-Dist: httpx>=0.27; extra == 'client'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# ng-postcode
|
|
31
|
+
|
|
32
|
+
Python library for Nigeria's National Digital Alphanumeric Postcode System (NDAPS), the building-level postcode NIPOST launched in October 2026. Parse, validate and format postcodes offline, and call the [postcode.gov.ng](https://docs.postcode.gov.ng) API for lookup, autocomplete and reverse geocoding.
|
|
33
|
+
|
|
34
|
+
Also available for Rust: [`ng-postcode` on crates.io](https://crates.io/crates/ng-postcode).
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install ng-postcode # offline core, no dependencies
|
|
38
|
+
pip install "ng-postcode[client]" # adds the API client (httpx)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Format
|
|
42
|
+
|
|
43
|
+
An 11-character code in five segments: state, LGA, district, area, building unit.
|
|
44
|
+
|
|
45
|
+
| Style | Example |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Canonical | `EK-01-A03-FK-01` |
|
|
48
|
+
| Display | `EK 01 A03 FK 01` |
|
|
49
|
+
| Compact | `EK01A03FK01` |
|
|
50
|
+
|
|
51
|
+
Compact form as a regular expression: `^[A-Z]{2}(0[1-9]|[1-9][0-9])[A-Z0-9]{3}[A-Z]{2}(0[1-9]|[1-9][0-9])$`
|
|
52
|
+
|
|
53
|
+
## Offline
|
|
54
|
+
|
|
55
|
+
Expected failures are returned as values, not raised, so the type checker makes you handle them.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from ng_postcode import Postcode, Segment, parse
|
|
59
|
+
|
|
60
|
+
match parse("ek 01 a03 fk 01"):
|
|
61
|
+
case Postcode() as code:
|
|
62
|
+
print(code) # EK-01-A03-FK-01
|
|
63
|
+
print(code.compact) # EK01A03FK01, store this
|
|
64
|
+
print(code.spaced) # EK 01 A03 FK 01
|
|
65
|
+
print(code.prefix(Segment.AREA)) # EK-01-A03-FK
|
|
66
|
+
case error:
|
|
67
|
+
print(error) # e.g. "invalid lga segment"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `parse` accepts hyphenated, spaced or compact input in either case.
|
|
71
|
+
- `parse_lenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
|
|
72
|
+
- `from_segments` assembles a code from its parts and zero-fills the LGA and unit.
|
|
73
|
+
- `Postcode` is immutable, hashable and sorts by state, LGA, district, area, unit.
|
|
74
|
+
|
|
75
|
+
A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from ng_postcode import Postcode, parse
|
|
81
|
+
from ng_postcode.api import lookup
|
|
82
|
+
from ng_postcode.client import Client
|
|
83
|
+
|
|
84
|
+
code = parse("EK-01-A03-FK-01")
|
|
85
|
+
assert isinstance(code, Postcode)
|
|
86
|
+
|
|
87
|
+
with Client(api_key="nipost_live_...") as client:
|
|
88
|
+
found = client.send(lookup(code, level=2))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`send` returns the typed response, an `ApiError` (for example `auth_required` or `insufficient_credits`) or a `TransportError`. `AsyncClient` has the same interface for asyncio.
|
|
92
|
+
|
|
93
|
+
`ng_postcode.api` covers lookup, autocomplete, reverse geocoding and nearby search. Each function returns a `Request` value and `decode` turns a status and body into a typed result, so it works with any HTTP client without the `client` extra.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# ng-postcode
|
|
2
|
+
|
|
3
|
+
Python library for Nigeria's National Digital Alphanumeric Postcode System (NDAPS), the building-level postcode NIPOST launched in October 2026. Parse, validate and format postcodes offline, and call the [postcode.gov.ng](https://docs.postcode.gov.ng) API for lookup, autocomplete and reverse geocoding.
|
|
4
|
+
|
|
5
|
+
Also available for Rust: [`ng-postcode` on crates.io](https://crates.io/crates/ng-postcode).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install ng-postcode # offline core, no dependencies
|
|
9
|
+
pip install "ng-postcode[client]" # adds the API client (httpx)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Format
|
|
13
|
+
|
|
14
|
+
An 11-character code in five segments: state, LGA, district, area, building unit.
|
|
15
|
+
|
|
16
|
+
| Style | Example |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| Canonical | `EK-01-A03-FK-01` |
|
|
19
|
+
| Display | `EK 01 A03 FK 01` |
|
|
20
|
+
| Compact | `EK01A03FK01` |
|
|
21
|
+
|
|
22
|
+
Compact form as a regular expression: `^[A-Z]{2}(0[1-9]|[1-9][0-9])[A-Z0-9]{3}[A-Z]{2}(0[1-9]|[1-9][0-9])$`
|
|
23
|
+
|
|
24
|
+
## Offline
|
|
25
|
+
|
|
26
|
+
Expected failures are returned as values, not raised, so the type checker makes you handle them.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from ng_postcode import Postcode, Segment, parse
|
|
30
|
+
|
|
31
|
+
match parse("ek 01 a03 fk 01"):
|
|
32
|
+
case Postcode() as code:
|
|
33
|
+
print(code) # EK-01-A03-FK-01
|
|
34
|
+
print(code.compact) # EK01A03FK01, store this
|
|
35
|
+
print(code.spaced) # EK 01 A03 FK 01
|
|
36
|
+
print(code.prefix(Segment.AREA)) # EK-01-A03-FK
|
|
37
|
+
case error:
|
|
38
|
+
print(error) # e.g. "invalid lga segment"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `parse` accepts hyphenated, spaced or compact input in either case.
|
|
42
|
+
- `parse_lenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
|
|
43
|
+
- `from_segments` assembles a code from its parts and zero-fills the LGA and unit.
|
|
44
|
+
- `Postcode` is immutable, hashable and sorts by state, LGA, district, area, unit.
|
|
45
|
+
|
|
46
|
+
A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from ng_postcode import Postcode, parse
|
|
52
|
+
from ng_postcode.api import lookup
|
|
53
|
+
from ng_postcode.client import Client
|
|
54
|
+
|
|
55
|
+
code = parse("EK-01-A03-FK-01")
|
|
56
|
+
assert isinstance(code, Postcode)
|
|
57
|
+
|
|
58
|
+
with Client(api_key="nipost_live_...") as client:
|
|
59
|
+
found = client.send(lookup(code, level=2))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`send` returns the typed response, an `ApiError` (for example `auth_required` or `insufficient_credits`) or a `TransportError`. `AsyncClient` has the same interface for asyncio.
|
|
63
|
+
|
|
64
|
+
`ng_postcode.api` covers lookup, autocomplete, reverse geocoding and nearby search. Each function returns a `Request` value and `decode` turns a status and body into a typed result, so it works with any HTTP client without the `client` extra.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ng-postcode"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Parse, validate and format Nigeria's NIPOST digital postcode (NDAPS) offline, plus a client for the postcode.gov.ng API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Kayode Adeniyi" }]
|
|
14
|
+
keywords = ["nigeria", "postcode", "nipost", "ndaps", "address", "address-validation", "geocoding", "postal-code"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
27
|
+
"Topic :: Software Development :: Libraries",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
client = ["httpx>=0.27"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/Adeniyikayodee/ng-postcode"
|
|
37
|
+
Issues = "https://github.com/Adeniyikayodee/ng-postcode/issues"
|
|
38
|
+
"API docs" = "https://docs.postcode.gov.ng"
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = ["httpx>=0.27", "mypy>=1.13", "pytest>=8", "ruff>=0.8"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
only-include = ["src", "tests", "README.md", "LICENSE"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 100
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "PT"]
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
strict = true
|
|
55
|
+
files = ["src", "tests"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests", "src"]
|
|
59
|
+
addopts = "--doctest-modules"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Nigeria's National Digital Alphanumeric Postcode (NDAPS), the building-level
|
|
2
|
+
postcode issued by NIPOST.
|
|
3
|
+
|
|
4
|
+
Expected failures come back as values, never exceptions:
|
|
5
|
+
|
|
6
|
+
>>> from ng_postcode import Postcode, Segment, parse
|
|
7
|
+
>>> code = parse("ek 01 a03 fk 01")
|
|
8
|
+
>>> isinstance(code, Postcode)
|
|
9
|
+
True
|
|
10
|
+
>>> str(code), code.compact, code.spaced
|
|
11
|
+
('EK-01-A03-FK-01', 'EK01A03FK01', 'EK 01 A03 FK 01')
|
|
12
|
+
>>> code.prefix(Segment.AREA)
|
|
13
|
+
'EK-01-A03-FK'
|
|
14
|
+
>>> str(parse("EK-01-A03"))
|
|
15
|
+
'expected 11 letters and digits, found 7'
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._postcode import (
|
|
19
|
+
Corrected,
|
|
20
|
+
InvalidCharacter,
|
|
21
|
+
InvalidSegment,
|
|
22
|
+
ParseError,
|
|
23
|
+
Postcode,
|
|
24
|
+
Segment,
|
|
25
|
+
WrongLength,
|
|
26
|
+
from_segments,
|
|
27
|
+
is_valid,
|
|
28
|
+
parse,
|
|
29
|
+
parse_lenient,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Corrected",
|
|
34
|
+
"InvalidCharacter",
|
|
35
|
+
"InvalidSegment",
|
|
36
|
+
"ParseError",
|
|
37
|
+
"Postcode",
|
|
38
|
+
"Segment",
|
|
39
|
+
"WrongLength",
|
|
40
|
+
"from_segments",
|
|
41
|
+
"is_valid",
|
|
42
|
+
"parse",
|
|
43
|
+
"parse_lenient",
|
|
44
|
+
]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Offline parsing, validation and formatting. Pure: no I/O, no mutation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import string
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TypeAlias
|
|
9
|
+
|
|
10
|
+
LENGTH = 11
|
|
11
|
+
|
|
12
|
+
_LETTERS = frozenset(string.ascii_uppercase)
|
|
13
|
+
_DIGITS = frozenset(string.digits)
|
|
14
|
+
_SEPARATORS = frozenset(" -")
|
|
15
|
+
_TO_LETTER = str.maketrans("0158", "OISB")
|
|
16
|
+
_TO_DIGIT = str.maketrans("OILSB", "01158")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Segment(Enum):
|
|
20
|
+
"""The five segments of a postcode, `AA-99-H77-BB-55`, widest first."""
|
|
21
|
+
|
|
22
|
+
STATE = "state"
|
|
23
|
+
LGA = "lga"
|
|
24
|
+
DISTRICT = "district"
|
|
25
|
+
AREA = "area"
|
|
26
|
+
UNIT = "unit"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_SPANS = {
|
|
30
|
+
Segment.STATE: range(0, 2),
|
|
31
|
+
Segment.LGA: range(2, 4),
|
|
32
|
+
Segment.DISTRICT: range(4, 7),
|
|
33
|
+
Segment.AREA: range(7, 9),
|
|
34
|
+
Segment.UNIT: range(9, 11),
|
|
35
|
+
}
|
|
36
|
+
_ALPHA = frozenset({Segment.STATE, Segment.AREA})
|
|
37
|
+
_NUMERIC = frozenset({Segment.LGA, Segment.UNIT})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class WrongLength:
|
|
42
|
+
"""The input did not hold exactly 11 letters and digits."""
|
|
43
|
+
|
|
44
|
+
found: int
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
return f"expected {LENGTH} letters and digits, found {self.found}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class InvalidCharacter:
|
|
52
|
+
"""The input held something other than letters, digits, spaces and hyphens."""
|
|
53
|
+
|
|
54
|
+
char: str
|
|
55
|
+
index: int
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str:
|
|
58
|
+
return f"invalid character {self.char!r} at index {self.index}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class InvalidSegment:
|
|
63
|
+
"""A segment has the wrong shape, such as digits in the state or `00` as a unit."""
|
|
64
|
+
|
|
65
|
+
segment: Segment
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
return f"invalid {self.segment.value} segment"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ParseError: TypeAlias = WrongLength | InvalidCharacter | InvalidSegment
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
75
|
+
class Postcode:
|
|
76
|
+
"""A well-formed postcode, held in its compact upper-case form.
|
|
77
|
+
|
|
78
|
+
Well formed is not the same as assigned: only the NIPOST API knows whether
|
|
79
|
+
a code belongs to a real building. Build one with `parse`; constructing it
|
|
80
|
+
from an unchecked string raises `ValueError`, as that is a programming error.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
compact: str
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
if _collect(self.compact) != self.compact or _validate(self.compact) is not None:
|
|
87
|
+
raise ValueError(f"not a compact upper-case postcode: {self.compact!r}")
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return self.prefix(Segment.UNIT)
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str:
|
|
93
|
+
return f"Postcode('{self}')"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def spaced(self) -> str:
|
|
97
|
+
"""The form shown to people, `EK 01 A03 FK 01`."""
|
|
98
|
+
return " ".join(self.segment(s) for s in Segment)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def state(self) -> str:
|
|
102
|
+
return self.segment(Segment.STATE)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def lga(self) -> str:
|
|
106
|
+
return self.segment(Segment.LGA)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def district(self) -> str:
|
|
110
|
+
return self.segment(Segment.DISTRICT)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def area(self) -> str:
|
|
114
|
+
return self.segment(Segment.AREA)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def unit(self) -> str:
|
|
118
|
+
return self.segment(Segment.UNIT)
|
|
119
|
+
|
|
120
|
+
def segment(self, segment: Segment) -> str:
|
|
121
|
+
return _part(self.compact, segment)
|
|
122
|
+
|
|
123
|
+
def prefix(self, through: Segment) -> str:
|
|
124
|
+
"""The hyphenated code down to `through`: `prefix(Segment.AREA)` is `EK-01-A03-FK`."""
|
|
125
|
+
order = list(Segment)
|
|
126
|
+
return "-".join(self.segment(s) for s in order[: order.index(through) + 1])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class Corrected:
|
|
131
|
+
"""The result of `parse_lenient`."""
|
|
132
|
+
|
|
133
|
+
postcode: Postcode
|
|
134
|
+
corrections: int
|
|
135
|
+
"""How many characters were swapped for their look-alike."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse(text: str) -> Postcode | ParseError:
|
|
139
|
+
"""Parse a hyphenated, spaced or compact code in either case.
|
|
140
|
+
|
|
141
|
+
>>> parse("ek 01 a03 fk 01")
|
|
142
|
+
Postcode('EK-01-A03-FK-01')
|
|
143
|
+
>>> parse("EK-00-A03-FK-01")
|
|
144
|
+
InvalidSegment(segment=<Segment.LGA: 'lga'>)
|
|
145
|
+
"""
|
|
146
|
+
compact = _collect(text)
|
|
147
|
+
if not isinstance(compact, str):
|
|
148
|
+
return compact
|
|
149
|
+
error = _validate(compact)
|
|
150
|
+
return error if error is not None else Postcode(compact)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_lenient(text: str) -> Corrected | ParseError:
|
|
154
|
+
"""Parse after swapping look-alikes that cannot occur where they stand: `0 1 5 8`
|
|
155
|
+
become `O I S B` where a letter is required, and `O I L S B` become `0 1 1 5 8`
|
|
156
|
+
where a digit is. The district allows both, so it is never rewritten.
|
|
157
|
+
|
|
158
|
+
The result is well formed but may not be the code the user meant, so confirm
|
|
159
|
+
it with them when `corrections` is not zero.
|
|
160
|
+
|
|
161
|
+
>>> parse_lenient("EK-O1-A03-FK-0I")
|
|
162
|
+
Corrected(postcode=Postcode('EK-01-A03-FK-01'), corrections=2)
|
|
163
|
+
"""
|
|
164
|
+
raw = _collect(text)
|
|
165
|
+
if not isinstance(raw, str):
|
|
166
|
+
return raw
|
|
167
|
+
fixed = "".join(_unconfuse(s, _part(raw, s)) for s in Segment)
|
|
168
|
+
error = _validate(fixed)
|
|
169
|
+
if error is not None:
|
|
170
|
+
return error
|
|
171
|
+
return Corrected(Postcode(fixed), sum(a != b for a, b in zip(raw, fixed, strict=True)))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def from_segments(
|
|
175
|
+
state: str, lga: str, district: str, area: str, unit: str
|
|
176
|
+
) -> Postcode | ParseError:
|
|
177
|
+
"""Build a code from its segments, zero-filling the LGA and unit.
|
|
178
|
+
|
|
179
|
+
>>> from_segments("ek", "1", "a03", "fk", "1")
|
|
180
|
+
Postcode('EK-01-A03-FK-01')
|
|
181
|
+
"""
|
|
182
|
+
values = (state, lga, district, area, unit)
|
|
183
|
+
padded = [_padded(s, v) for s, v in zip(Segment, values, strict=True)]
|
|
184
|
+
error = next((p for p in padded if isinstance(p, InvalidSegment)), None)
|
|
185
|
+
if error is not None:
|
|
186
|
+
return error
|
|
187
|
+
return parse("".join(p for p in padded if isinstance(p, str)))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_valid(text: str) -> bool:
|
|
191
|
+
"""Whether `text` is a well-formed postcode."""
|
|
192
|
+
return isinstance(parse(text), Postcode)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _collect(text: str) -> str | ParseError:
|
|
196
|
+
kept = [(index, char) for index, char in enumerate(text) if char not in _SEPARATORS]
|
|
197
|
+
invalid = next(((i, c) for i, c in kept if not (c.isascii() and c.isalnum())), None)
|
|
198
|
+
if invalid is not None:
|
|
199
|
+
return InvalidCharacter(char=invalid[1], index=invalid[0])
|
|
200
|
+
if len(kept) != LENGTH:
|
|
201
|
+
return WrongLength(found=len(kept))
|
|
202
|
+
return "".join(char for _, char in kept).upper()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _part(compact: str, segment: Segment) -> str:
|
|
206
|
+
span = _SPANS[segment]
|
|
207
|
+
return compact[span.start : span.stop]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validate(compact: str) -> InvalidSegment | None:
|
|
211
|
+
return next((InvalidSegment(s) for s in Segment if not _accepts(s, _part(compact, s))), None)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _accepts(segment: Segment, text: str) -> bool:
|
|
215
|
+
chars = set(text)
|
|
216
|
+
if segment in _ALPHA:
|
|
217
|
+
return chars <= _LETTERS
|
|
218
|
+
if segment in _NUMERIC:
|
|
219
|
+
return chars <= _DIGITS and text != "00"
|
|
220
|
+
return chars <= _LETTERS | _DIGITS
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _unconfuse(segment: Segment, text: str) -> str:
|
|
224
|
+
if segment in _ALPHA:
|
|
225
|
+
return text.translate(_TO_LETTER)
|
|
226
|
+
if segment in _NUMERIC:
|
|
227
|
+
return text.translate(_TO_DIGIT)
|
|
228
|
+
return text
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _padded(segment: Segment, value: str) -> str | InvalidSegment:
|
|
232
|
+
text, width = value.strip(), len(_SPANS[segment])
|
|
233
|
+
shortest = 1 if segment in _NUMERIC else width
|
|
234
|
+
fits = shortest <= len(text) <= width and text.isascii() and text.isalnum()
|
|
235
|
+
return text.rjust(width, "0") if fits else InvalidSegment(segment)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The NIPOST Postcode API as plain data: requests to send and responses to decode.
|
|
2
|
+
|
|
3
|
+
Nothing here performs I/O, so it works with any HTTP client, sync or async.
|
|
4
|
+
Assembly and disassembly are not modelled: `parse` and `from_segments` do both
|
|
5
|
+
offline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Generic, TypeVar
|
|
14
|
+
|
|
15
|
+
from ._postcode import Postcode, Segment
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
BASE_URL = "https://api.postcode.gov.ng"
|
|
20
|
+
|
|
21
|
+
Params = tuple[tuple[str, str], ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Request(Generic[T]):
|
|
26
|
+
"""A GET request whose successful response decodes to `T`."""
|
|
27
|
+
|
|
28
|
+
path: str
|
|
29
|
+
params: Params
|
|
30
|
+
read: Callable[[Any], T | None] = field(repr=False, compare=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Coordinate:
|
|
35
|
+
lat: float
|
|
36
|
+
lng: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ApiError:
|
|
41
|
+
"""The API refused the request, with `code` taken from its error envelope
|
|
42
|
+
(`auth_required`, `insufficient_credits`, ...), or its answer was not the
|
|
43
|
+
documented envelope, with `code` set to `malformed_response`."""
|
|
44
|
+
|
|
45
|
+
status: int
|
|
46
|
+
code: str
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"{self.code} ({self.status}): {self.message}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class AdministrativeAddress:
|
|
55
|
+
state_name: str | None
|
|
56
|
+
lga_name: str | None
|
|
57
|
+
locality_name: str | None
|
|
58
|
+
zone: str | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Lookup:
|
|
63
|
+
"""Fields above the level granted to the key are `None`."""
|
|
64
|
+
|
|
65
|
+
postcode: str
|
|
66
|
+
valid: bool
|
|
67
|
+
administrative_address: AdministrativeAddress | None
|
|
68
|
+
"""Level 2."""
|
|
69
|
+
recent_house_address: str | None
|
|
70
|
+
"""Level 2."""
|
|
71
|
+
building_use_status: str | None
|
|
72
|
+
"""Level 3."""
|
|
73
|
+
other_building_info: Any
|
|
74
|
+
"""Level 4. Undocumented, so left as raw JSON."""
|
|
75
|
+
point_geometry: Any
|
|
76
|
+
"""Level 5. Undocumented, so left as raw JSON."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Suggestion:
|
|
81
|
+
code: str
|
|
82
|
+
label: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Autocomplete:
|
|
87
|
+
segment: Segment | None
|
|
88
|
+
"""The segment the suggestions complete."""
|
|
89
|
+
suggestions: tuple[Suggestion, ...]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class NearestUnit:
|
|
94
|
+
postcode: str
|
|
95
|
+
display: str
|
|
96
|
+
distance_m: float | None
|
|
97
|
+
confidence: str | None
|
|
98
|
+
"""`high`, `medium` or `low`, graded by distance."""
|
|
99
|
+
state_name: str | None
|
|
100
|
+
lga_name: str | None
|
|
101
|
+
locality_name: str | None
|
|
102
|
+
address: str | None
|
|
103
|
+
"""Recent house address. This and the names above need level 2."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class Reverse:
|
|
108
|
+
found: bool
|
|
109
|
+
coordinate: Coordinate | None
|
|
110
|
+
"""The queried point, echoed back."""
|
|
111
|
+
unit: NearestUnit | None
|
|
112
|
+
"""The nearest building, absent when nothing is in range."""
|
|
113
|
+
area: str | None
|
|
114
|
+
district: str | None
|
|
115
|
+
state: str | None
|
|
116
|
+
message: str | None
|
|
117
|
+
"""Set when nothing is in range."""
|
|
118
|
+
radius_m: float | None
|
|
119
|
+
"""The radius the API actually applied."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lookup(code: Postcode, level: int = 1) -> Request[Lookup]:
|
|
123
|
+
"""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."""
|
|
125
|
+
return Request("/v1/lookup", (("code", str(code)), ("level", str(level))), _lookup)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def autocomplete(partial: str) -> Request[Autocomplete]:
|
|
129
|
+
"""Suggest completions for a partial postcode such as `EK 01 A`."""
|
|
130
|
+
return Request("/v1/search/autocomplete", (("q", partial),), _autocomplete)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def reverse(at: Coordinate, max_distance_m: float | None = None) -> Request[Reverse]:
|
|
134
|
+
"""Find the postcode of the nearest building, within 25 m unless `max_distance_m`
|
|
135
|
+
says otherwise. The API clamps it to 250 m."""
|
|
136
|
+
return Request("/v1/search/reverse", _around(at, "max_distance_m", max_distance_m), _reverse)
|
|
137
|
+
|
|
138
|
+
|
|
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)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def decode(request: Request[T], status: int, body: str) -> T | ApiError:
|
|
146
|
+
"""Decode the response to `request` from its status and body."""
|
|
147
|
+
try:
|
|
148
|
+
envelope = json.loads(body)
|
|
149
|
+
except ValueError as error:
|
|
150
|
+
return _malformed(status, f"not JSON: {error}")
|
|
151
|
+
if not isinstance(envelope, dict):
|
|
152
|
+
return _malformed(status, "expected a JSON object")
|
|
153
|
+
failure = _object(envelope, "error")
|
|
154
|
+
if failure is not None:
|
|
155
|
+
code = _text(failure, "code") or "unknown_error"
|
|
156
|
+
return ApiError(status, code, _text(failure, "message") or "")
|
|
157
|
+
data = request.read(envelope.get("data"))
|
|
158
|
+
return data if data is not None else _malformed(status, "unexpected data")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _around(at: Coordinate, key: str, metres: float | None) -> Params:
|
|
162
|
+
point = (("lat", _number_text(at.lat)), ("lng", _number_text(at.lng)))
|
|
163
|
+
return point if metres is None else (*point, (key, _number_text(metres)))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _number_text(value: float) -> str:
|
|
167
|
+
number = float(value)
|
|
168
|
+
return str(int(number)) if number.is_integer() else repr(number)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _malformed(status: int, message: str) -> ApiError:
|
|
172
|
+
return ApiError(status, "malformed_response", message)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _object(data: Mapping[str, Any], key: str) -> Mapping[str, Any] | None:
|
|
176
|
+
value = data.get(key)
|
|
177
|
+
return value if isinstance(value, dict) else None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _text(data: Mapping[str, Any], key: str) -> str | None:
|
|
181
|
+
value = data.get(key)
|
|
182
|
+
return value if isinstance(value, str) else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _number(data: Mapping[str, Any], key: str) -> float | None:
|
|
186
|
+
return _float(data.get(key))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _float(value: Any) -> float | None:
|
|
190
|
+
is_number = isinstance(value, int | float) and not isinstance(value, bool)
|
|
191
|
+
return float(value) if is_number else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _raw(data: Any) -> Any:
|
|
195
|
+
return data
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _lookup(data: Any) -> Lookup | None:
|
|
199
|
+
if not isinstance(data, dict):
|
|
200
|
+
return None
|
|
201
|
+
admin = _object(data, "administrative_address")
|
|
202
|
+
recent = _object(data, "recent_house_address")
|
|
203
|
+
return Lookup(
|
|
204
|
+
postcode=_text(data, "postcode") or "",
|
|
205
|
+
valid=data.get("valid") is True,
|
|
206
|
+
administrative_address=None
|
|
207
|
+
if admin is None
|
|
208
|
+
else AdministrativeAddress(
|
|
209
|
+
state_name=_text(admin, "state_name"),
|
|
210
|
+
lga_name=_text(admin, "lga_name"),
|
|
211
|
+
locality_name=_text(admin, "locality_name"),
|
|
212
|
+
zone=_text(admin, "zone"),
|
|
213
|
+
),
|
|
214
|
+
recent_house_address=None if recent is None else _text(recent, "recent"),
|
|
215
|
+
building_use_status=_text(data, "building_use_status"),
|
|
216
|
+
other_building_info=data.get("other_building_info"),
|
|
217
|
+
point_geometry=data.get("point_geometry"),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _autocomplete(data: Any) -> Autocomplete | None:
|
|
222
|
+
if not isinstance(data, dict):
|
|
223
|
+
return None
|
|
224
|
+
items = data.get("suggestions")
|
|
225
|
+
suggestions = tuple(
|
|
226
|
+
Suggestion(code=_text(item, "code") or "", label=_text(item, "label") or "")
|
|
227
|
+
for item in (items if isinstance(items, list) else [])
|
|
228
|
+
if isinstance(item, dict)
|
|
229
|
+
)
|
|
230
|
+
segment = next((s for s in Segment if s.value == data.get("segment")), None)
|
|
231
|
+
return Autocomplete(segment=segment, suggestions=suggestions)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _reverse(data: Any) -> Reverse | None:
|
|
235
|
+
if not isinstance(data, dict):
|
|
236
|
+
return None
|
|
237
|
+
unit = _object(data, "unit")
|
|
238
|
+
return Reverse(
|
|
239
|
+
found=data.get("found") is True,
|
|
240
|
+
coordinate=_coordinate(data.get("coordinate")),
|
|
241
|
+
unit=None
|
|
242
|
+
if unit is None
|
|
243
|
+
else NearestUnit(
|
|
244
|
+
postcode=_text(unit, "postcode") or "",
|
|
245
|
+
display=_text(unit, "display") or "",
|
|
246
|
+
distance_m=_number(unit, "distance_m"),
|
|
247
|
+
confidence=_text(unit, "confidence"),
|
|
248
|
+
state_name=_text(unit, "state_name"),
|
|
249
|
+
lga_name=_text(unit, "lga_name"),
|
|
250
|
+
locality_name=_text(unit, "locality_name"),
|
|
251
|
+
address=_text(unit, "address"),
|
|
252
|
+
),
|
|
253
|
+
area=_text(data, "area"),
|
|
254
|
+
district=_text(data, "district"),
|
|
255
|
+
state=_text(data, "state"),
|
|
256
|
+
message=_text(data, "message"),
|
|
257
|
+
radius_m=_number(data, "radius_m"),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _coordinate(value: Any) -> Coordinate | None:
|
|
262
|
+
"""The API echoes points as `[lng, lat]`."""
|
|
263
|
+
if not isinstance(value, list) or len(value) != 2:
|
|
264
|
+
return None
|
|
265
|
+
lng, lat = (_float(v) for v in value)
|
|
266
|
+
return None if lng is None or lat is None else Coordinate(lat=lat, lng=lng)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HTTP shell over `ng_postcode.api`, built on httpx: the only module that performs I/O.
|
|
2
|
+
|
|
3
|
+
Install with `pip install "ng-postcode[client]"`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .api import BASE_URL, ApiError, Request, decode
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
TIMEOUT = 10.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class TransportError:
|
|
22
|
+
"""The request never produced a response: DNS, TLS, timeout and the like."""
|
|
23
|
+
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return self.reason
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Client:
|
|
31
|
+
"""Blocking client. Pass `http` to reuse your own `httpx.Client`; it stays yours to close."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, api_key: str, *, base_url: str = BASE_URL, http: httpx.Client | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
self._http = http if http is not None else httpx.Client(timeout=TIMEOUT)
|
|
37
|
+
self._owns_http = http is None
|
|
38
|
+
self._base_url = base_url
|
|
39
|
+
self._headers = {"X-API-Key": api_key}
|
|
40
|
+
|
|
41
|
+
def send(self, request: Request[T]) -> T | ApiError | TransportError:
|
|
42
|
+
url = self._base_url + request.path
|
|
43
|
+
try:
|
|
44
|
+
response = self._http.get(url, params=request.params, headers=self._headers)
|
|
45
|
+
except httpx.HTTPError as error:
|
|
46
|
+
return _transport(error)
|
|
47
|
+
return decode(request, response.status_code, response.text)
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
if self._owns_http:
|
|
51
|
+
self._http.close()
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> Client:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
57
|
+
self.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AsyncClient:
|
|
61
|
+
"""Async client. Pass `http` to reuse your own `httpx.AsyncClient`; it stays yours to close."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self, api_key: str, *, base_url: str = BASE_URL, http: httpx.AsyncClient | None = None
|
|
65
|
+
) -> None:
|
|
66
|
+
self._http = http if http is not None else httpx.AsyncClient(timeout=TIMEOUT)
|
|
67
|
+
self._owns_http = http is None
|
|
68
|
+
self._base_url = base_url
|
|
69
|
+
self._headers = {"X-API-Key": api_key}
|
|
70
|
+
|
|
71
|
+
async def send(self, request: Request[T]) -> T | ApiError | TransportError:
|
|
72
|
+
url = self._base_url + request.path
|
|
73
|
+
try:
|
|
74
|
+
response = await self._http.get(url, params=request.params, headers=self._headers)
|
|
75
|
+
except httpx.HTTPError as error:
|
|
76
|
+
return _transport(error)
|
|
77
|
+
return decode(request, response.status_code, response.text)
|
|
78
|
+
|
|
79
|
+
async def aclose(self) -> None:
|
|
80
|
+
if self._owns_http:
|
|
81
|
+
await self._http.aclose()
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> AsyncClient:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
87
|
+
await self.aclose()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _transport(error: httpx.HTTPError) -> TransportError:
|
|
91
|
+
return TransportError(str(error) or type(error).__name__)
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from ng_postcode import Postcode, Segment, parse
|
|
6
|
+
from ng_postcode.api import (
|
|
7
|
+
AdministrativeAddress,
|
|
8
|
+
ApiError,
|
|
9
|
+
Coordinate,
|
|
10
|
+
autocomplete,
|
|
11
|
+
decode,
|
|
12
|
+
lookup,
|
|
13
|
+
nearby,
|
|
14
|
+
reverse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
HERE = Coordinate(lat=7.62, lng=5.22)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def code(text: str) -> Postcode:
|
|
21
|
+
parsed = parse(text)
|
|
22
|
+
assert isinstance(parsed, Postcode)
|
|
23
|
+
return parsed
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def body(data: object) -> str:
|
|
27
|
+
return json.dumps({"data": data})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_builds_requests() -> None:
|
|
31
|
+
request = lookup(code("ek01a03fk01"), 3)
|
|
32
|
+
assert (request.path, request.params) == (
|
|
33
|
+
"/v1/lookup",
|
|
34
|
+
(("code", "EK-01-A03-FK-01"), ("level", "3")),
|
|
35
|
+
)
|
|
36
|
+
assert autocomplete("EK 01 A").params == (("q", "EK 01 A"),)
|
|
37
|
+
assert reverse(HERE, 100.0).params == (
|
|
38
|
+
("lat", "7.62"),
|
|
39
|
+
("lng", "5.22"),
|
|
40
|
+
("max_distance_m", "100"),
|
|
41
|
+
)
|
|
42
|
+
assert nearby(HERE).params == (("lat", "7.62"), ("lng", "5.22"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_decodes_the_documented_lookup() -> None:
|
|
46
|
+
data = {
|
|
47
|
+
"postcode": "EK-01-A03-FK-01",
|
|
48
|
+
"valid": True,
|
|
49
|
+
"administrative_address": {
|
|
50
|
+
"state_name": "EKITI",
|
|
51
|
+
"lga_name": "ADO EKITI",
|
|
52
|
+
"locality_name": "ADO EKITI",
|
|
53
|
+
"zone": "SOUTH WEST",
|
|
54
|
+
},
|
|
55
|
+
"recent_house_address": {"recent": "NTA ROAD, BACK OF FABIAN HOTEL, ADO EKITI"},
|
|
56
|
+
"building_use_status": "residential",
|
|
57
|
+
}
|
|
58
|
+
found = decode(lookup(code("EK-01-A03-FK-01"), 3), 200, body(data))
|
|
59
|
+
assert not isinstance(found, ApiError)
|
|
60
|
+
assert found.valid
|
|
61
|
+
assert found.administrative_address == AdministrativeAddress(
|
|
62
|
+
"EKITI", "ADO EKITI", "ADO EKITI", "SOUTH WEST"
|
|
63
|
+
)
|
|
64
|
+
assert found.recent_house_address == "NTA ROAD, BACK OF FABIAN HOTEL, ADO EKITI"
|
|
65
|
+
assert (found.building_use_status, found.point_geometry) == ("residential", None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_fields_above_the_granted_level_are_none() -> None:
|
|
69
|
+
data = {"postcode": "EK-01-A03-FK-01", "valid": True}
|
|
70
|
+
found = decode(lookup(code("EK-01-A03-FK-01")), 200, body(data))
|
|
71
|
+
assert not isinstance(found, ApiError)
|
|
72
|
+
assert (found.administrative_address, found.recent_house_address) == (None, None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_decodes_autocomplete_reverse_and_nearby() -> None:
|
|
76
|
+
data = {"segment": "lga", "suggestions": [{"code": "EK-01", "label": "ADO EKITI"}]}
|
|
77
|
+
found = decode(autocomplete("EK"), 200, body(data))
|
|
78
|
+
assert not isinstance(found, ApiError)
|
|
79
|
+
assert found.segment is Segment.LGA
|
|
80
|
+
assert found.suggestions[0].code == "EK-01"
|
|
81
|
+
|
|
82
|
+
miss = {"found": False, "coordinate": [5.22, 7.62], "message": "none", "radius_m": 25}
|
|
83
|
+
result = decode(reverse(HERE), 200, body(miss))
|
|
84
|
+
assert not isinstance(result, ApiError)
|
|
85
|
+
assert (result.found, result.unit, result.radius_m) == (False, None, 25.0)
|
|
86
|
+
assert result.coordinate == HERE
|
|
87
|
+
|
|
88
|
+
assert decode(nearby(HERE), 200, body({"results": []})) == {"results": []}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_error_envelopes_and_bad_bodies_become_values() -> None:
|
|
92
|
+
request = lookup(code("EK-01-A03-FK-01"))
|
|
93
|
+
# What the live API answered on 2 October 2026 when called without a key.
|
|
94
|
+
refused = '{"error":{"code":"auth_required","message":"an API key is required"}}'
|
|
95
|
+
assert decode(request, 401, refused) == ApiError(401, "auth_required", "an API key is required")
|
|
96
|
+
for status, text in [(502, "<html>Bad Gateway</html>"), (200, "[]"), (200, '{"data": 1}')]:
|
|
97
|
+
error = decode(request, status, text)
|
|
98
|
+
assert isinstance(error, ApiError)
|
|
99
|
+
assert (error.status, error.code) == (status, "malformed_response")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ng_postcode import Postcode
|
|
9
|
+
from ng_postcode.api import ApiError, Lookup, lookup
|
|
10
|
+
from ng_postcode.client import AsyncClient, Client, TransportError
|
|
11
|
+
|
|
12
|
+
CODE = Postcode("EK01A03FK01")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def api(request: httpx.Request) -> httpx.Response:
|
|
16
|
+
if request.headers.get("X-API-Key") != "key":
|
|
17
|
+
error = {"code": "invalid_api_key", "message": "the provided API key is invalid"}
|
|
18
|
+
return httpx.Response(401, json={"error": error})
|
|
19
|
+
assert request.url.path == "/v1/lookup"
|
|
20
|
+
assert dict(request.url.params) == {"code": "EK-01-A03-FK-01", "level": "1"}
|
|
21
|
+
return httpx.Response(200, json={"data": {"postcode": "EK-01-A03-FK-01", "valid": True}})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def unreachable(request: httpx.Request) -> httpx.Response:
|
|
25
|
+
raise httpx.ConnectError("connection refused", request=request)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sync_client(key: str, handler: Callable[[httpx.Request], httpx.Response]) -> Client:
|
|
29
|
+
return Client(key, http=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_sends_the_key_and_decodes_the_answer() -> None:
|
|
33
|
+
with sync_client("key", api) as client:
|
|
34
|
+
found = client.send(lookup(CODE))
|
|
35
|
+
assert isinstance(found, Lookup)
|
|
36
|
+
assert (found.postcode, found.valid) == ("EK-01-A03-FK-01", True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_api_and_network_failures_are_values() -> None:
|
|
40
|
+
with sync_client("wrong", api) as client:
|
|
41
|
+
assert client.send(lookup(CODE)) == ApiError(
|
|
42
|
+
401, "invalid_api_key", "the provided API key is invalid"
|
|
43
|
+
)
|
|
44
|
+
with sync_client("key", unreachable) as client:
|
|
45
|
+
assert client.send(lookup(CODE)) == TransportError("connection refused")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_only_closes_the_http_client_it_created() -> None:
|
|
49
|
+
http = httpx.Client(transport=httpx.MockTransport(api))
|
|
50
|
+
with Client("key", http=http):
|
|
51
|
+
pass
|
|
52
|
+
assert not http.is_closed
|
|
53
|
+
http.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_async_client() -> None:
|
|
57
|
+
async def run() -> Lookup | ApiError | TransportError:
|
|
58
|
+
http = httpx.AsyncClient(transport=httpx.MockTransport(api))
|
|
59
|
+
async with AsyncClient("key", http=http) as client:
|
|
60
|
+
return await client.send(lookup(CODE))
|
|
61
|
+
|
|
62
|
+
found = asyncio.run(run())
|
|
63
|
+
assert isinstance(found, Lookup)
|
|
64
|
+
assert found.valid
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Runs the shared cases in `spec/vectors.json`, which every implementation must pass."""
|
|
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 (
|
|
12
|
+
Corrected,
|
|
13
|
+
InvalidCharacter,
|
|
14
|
+
InvalidSegment,
|
|
15
|
+
ParseError,
|
|
16
|
+
Postcode,
|
|
17
|
+
Segment,
|
|
18
|
+
WrongLength,
|
|
19
|
+
from_segments,
|
|
20
|
+
parse,
|
|
21
|
+
parse_lenient,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
VECTORS = Path(__file__).resolve().parents[2] / "spec" / "vectors.json"
|
|
25
|
+
CASES: dict[str, Any] = json.loads(VECTORS.read_text(encoding="utf-8")) if VECTORS.exists() else {}
|
|
26
|
+
|
|
27
|
+
pytestmark = pytest.mark.skipif(
|
|
28
|
+
not CASES, reason="spec/vectors.json lives in the repository, not the sdist"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def outcome(result: Postcode | Corrected | ParseError) -> dict[str, Any]:
|
|
33
|
+
match result:
|
|
34
|
+
case Postcode():
|
|
35
|
+
return {"canonical": str(result)}
|
|
36
|
+
case Corrected(postcode, corrections):
|
|
37
|
+
return {"canonical": str(postcode), "corrections": corrections}
|
|
38
|
+
case WrongLength(found):
|
|
39
|
+
return {"error": {"kind": "length", "found": found}}
|
|
40
|
+
case InvalidCharacter(char, index):
|
|
41
|
+
return {"error": {"kind": "invalid_character", "char": char, "index": index}}
|
|
42
|
+
case InvalidSegment(segment):
|
|
43
|
+
return {"error": {"kind": "segment", "segment": segment.value}}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def expected(case: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
return {k: v for k, v in case.items() if k in ("canonical", "corrections", "error")}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize("case", CASES.get("parse", {}).get("valid", []))
|
|
51
|
+
def test_parse_valid(case: dict[str, Any]) -> None:
|
|
52
|
+
code = parse(case["input"])
|
|
53
|
+
assert isinstance(code, Postcode)
|
|
54
|
+
assert (str(code), code.compact, code.spaced) == (
|
|
55
|
+
case["canonical"],
|
|
56
|
+
case["compact"],
|
|
57
|
+
case["spaced"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize("case", CASES.get("parse", {}).get("invalid", []))
|
|
62
|
+
def test_parse_invalid(case: dict[str, Any]) -> None:
|
|
63
|
+
assert outcome(parse(case["input"])) == expected(case)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("case", CASES.get("parse_lenient", []))
|
|
67
|
+
def test_parse_lenient(case: dict[str, Any]) -> None:
|
|
68
|
+
assert outcome(parse_lenient(case["input"])) == expected(case)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize("case", CASES.get("from_segments", []))
|
|
72
|
+
def test_from_segments(case: dict[str, Any]) -> None:
|
|
73
|
+
assert outcome(from_segments(*case["segments"])) == expected(case)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("case", CASES.get("prefix", []))
|
|
77
|
+
def test_prefix(case: dict[str, Any]) -> None:
|
|
78
|
+
code = parse(case["input"])
|
|
79
|
+
assert isinstance(code, Postcode)
|
|
80
|
+
assert code.prefix(Segment(case["through"])) == case["prefix"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Python-specific behaviour. Shared parsing rules live in test_conformance.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from ng_postcode import (
|
|
10
|
+
Corrected,
|
|
11
|
+
InvalidCharacter,
|
|
12
|
+
InvalidSegment,
|
|
13
|
+
Postcode,
|
|
14
|
+
Segment,
|
|
15
|
+
WrongLength,
|
|
16
|
+
is_valid,
|
|
17
|
+
parse,
|
|
18
|
+
parse_lenient,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ok(text: str) -> Postcode:
|
|
23
|
+
code = parse(text)
|
|
24
|
+
assert isinstance(code, Postcode), code
|
|
25
|
+
return code
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_errors_are_values_with_readable_messages() -> None:
|
|
29
|
+
assert parse("EK-01") == WrongLength(found=4)
|
|
30
|
+
assert str(WrongLength(found=5)) == "expected 11 letters and digits, found 5"
|
|
31
|
+
assert str(InvalidCharacter(char="_", index=2)) == "invalid character '_' at index 2"
|
|
32
|
+
assert str(InvalidSegment(Segment.LGA)) == "invalid lga segment"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_exposes_segments() -> None:
|
|
36
|
+
code = ok("la11w06tc10")
|
|
37
|
+
assert (code.state, code.lga, code.district, code.area, code.unit) == (
|
|
38
|
+
"LA",
|
|
39
|
+
"11",
|
|
40
|
+
"W06",
|
|
41
|
+
"TC",
|
|
42
|
+
"10",
|
|
43
|
+
)
|
|
44
|
+
assert repr(code) == "Postcode('LA-11-W06-TC-10')"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_postcodes_are_immutable_hashable_and_ordered() -> None:
|
|
48
|
+
code = ok("EK-01-A03-FK-01")
|
|
49
|
+
assert code == ok("ek 01 a03 fk 01")
|
|
50
|
+
assert len({code, ok("ek01a03fk01")}) == 1
|
|
51
|
+
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
52
|
+
setattr(code, "compact", "LA11W06TC10") # noqa: B010
|
|
53
|
+
codes = [ok("LA-11-W06-TC-10"), ok("EK-01-A03-FK-02"), code]
|
|
54
|
+
assert [str(c) for c in sorted(codes)] == [
|
|
55
|
+
"EK-01-A03-FK-01",
|
|
56
|
+
"EK-01-A03-FK-02",
|
|
57
|
+
"LA-11-W06-TC-10",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize("text", ["ek01a03fk01", "EK-01-A03-FK-01", "EK00A03FK01", ""])
|
|
62
|
+
def test_direct_construction_demands_the_compact_form(text: str) -> None:
|
|
63
|
+
with pytest.raises(ValueError, match="not a compact upper-case postcode"):
|
|
64
|
+
Postcode(text)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_lenient_counts_corrections() -> None:
|
|
68
|
+
assert parse_lenient("EK-O1-A03-FK-0I") == Corrected(ok("EK-01-A03-FK-01"), 2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_is_valid_agrees_with_parse() -> None:
|
|
72
|
+
assert is_valid("ek 01 a03 fk 01")
|
|
73
|
+
assert not is_valid("EK-01-A03")
|