locio 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.
- locio-0.1.0/.github/workflows/ci.yml +21 -0
- locio-0.1.0/.github/workflows/release.yml +25 -0
- locio-0.1.0/.gitignore +6 -0
- locio-0.1.0/CHANGELOG.md +6 -0
- locio-0.1.0/LICENSE +21 -0
- locio-0.1.0/PKG-INFO +198 -0
- locio-0.1.0/README.md +151 -0
- locio-0.1.0/pyproject.toml +51 -0
- locio-0.1.0/src/locio/__init__.py +36 -0
- locio-0.1.0/src/locio/_client.py +207 -0
- locio-0.1.0/src/locio/_errors.py +44 -0
- locio-0.1.0/src/locio/_models.py +195 -0
- locio-0.1.0/src/locio/py.typed +0 -0
- locio-0.1.0/tests/test_client.py +205 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
# No install step: the library has no dependencies and the suite runs
|
|
20
|
+
# on stdlib unittest, so this is the real "does it work from scratch".
|
|
21
|
+
- run: PYTHONPATH=src python -m unittest discover -s tests -v
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
# Trusted publishing: PyPI verifies this workflow's identity, so there is no
|
|
8
|
+
# long lived API token in the repository to leak. Configure the publisher at
|
|
9
|
+
# pypi.org/manage/project/locio/settings/publishing/ before the first tag.
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
publish:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
environment: pypi
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
- run: PYTHONPATH=src python -m unittest discover -s tests
|
|
24
|
+
- run: pip install build && python -m build
|
|
25
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
locio-0.1.0/.gitignore
ADDED
locio-0.1.0/CHANGELOG.md
ADDED
locio-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Locio
|
|
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.
|
locio-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: locio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Australian address autocomplete, validation and geocoding from G-NAF
|
|
5
|
+
Project-URL: Homepage, https://locio.com.au
|
|
6
|
+
Project-URL: Documentation, https://locio.com.au/docs/
|
|
7
|
+
Project-URL: Source, https://github.com/locio-au/locio-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/locio-au/locio-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Locio
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Locio
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: address autocomplete,address geocoding,address validation,address validation service,australia,australian address api,g-naf,geocoding,gnaf,postcode
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
43
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
44
|
+
Classifier: Typing :: Typed
|
|
45
|
+
Requires-Python: >=3.9
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# locio
|
|
49
|
+
|
|
50
|
+
**Australian address validation, address autocomplete and address geocoding**
|
|
51
|
+
in Python, from G-NAF, the national address register.
|
|
52
|
+
|
|
53
|
+
One call gives you a stable G-NAF id, a coordinate, an ABS mesh block and the
|
|
54
|
+
address split into fields. Comparable services bill validate, geocode and
|
|
55
|
+
meshblock separately; here they are fields of one response.
|
|
56
|
+
|
|
57
|
+
No dependencies. Python 3.9+.
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pip install locio
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Address validation
|
|
64
|
+
|
|
65
|
+
`resolve` takes an address however you hold it and tells you whether it is
|
|
66
|
+
real, where it is, and what it is made of.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from locio import Locio
|
|
70
|
+
|
|
71
|
+
locio = Locio("lc_live_...")
|
|
72
|
+
|
|
73
|
+
result = locio.resolve("1 george st sydenham nsw 2044")
|
|
74
|
+
|
|
75
|
+
if result.matched:
|
|
76
|
+
a = result.address
|
|
77
|
+
print(a.formatted) # 1 George Street, Sydenham NSW 2044
|
|
78
|
+
print(a.address_detail_pid) # store this, not the text
|
|
79
|
+
print(a.lat, a.lng) # geocoded
|
|
80
|
+
print(a.mesh_block) # ABS mesh block
|
|
81
|
+
print(a.components.postcode) # parsed
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`matched` is `False` for an address that is not in G-NAF. That is an ordinary
|
|
85
|
+
answer, not an error, and it is what a validation call is asking. `Resolution`
|
|
86
|
+
is falsy when nothing matched, so this reads the way you want:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
if not locio.resolve(typed):
|
|
90
|
+
... # ask the customer to check it
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Cleaning a spreadsheet
|
|
94
|
+
|
|
95
|
+
The commonest thing this library gets asked to do. `to_dict()` flattens a
|
|
96
|
+
record to one level so it goes straight into a `DictWriter`.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import csv
|
|
100
|
+
from locio import Locio
|
|
101
|
+
|
|
102
|
+
locio = Locio("lc_live_...")
|
|
103
|
+
|
|
104
|
+
with open("customers.csv") as f, open("clean.csv", "w", newline="") as out:
|
|
105
|
+
rows = list(csv.DictReader(f))
|
|
106
|
+
writer = None
|
|
107
|
+
|
|
108
|
+
for row in rows:
|
|
109
|
+
result = locio.resolve(row["address"])
|
|
110
|
+
clean = result.address.to_dict() if result.matched else {}
|
|
111
|
+
record = {**row, **clean, "matched": result.matched}
|
|
112
|
+
|
|
113
|
+
if writer is None:
|
|
114
|
+
writer = csv.DictWriter(out, fieldnames=list(record))
|
|
115
|
+
writer.writeheader()
|
|
116
|
+
writer.writerow(record)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`resolve_many` does the same sequentially, which is deliberate: the quota is
|
|
120
|
+
per key, and firing a thousand requests at once is how a free tier is spent in
|
|
121
|
+
a second.
|
|
122
|
+
|
|
123
|
+
## Address autocomplete
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
for a in locio.search("104/119 turner", limit=8):
|
|
127
|
+
print(a.formatted, a.address_detail_pid)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For a browser autocomplete use [`@locio-au/react`](https://www.npmjs.com/package/@locio-au/react)
|
|
131
|
+
or [`@locio-au/vue`](https://www.npmjs.com/package/@locio-au/vue) with a **public**
|
|
132
|
+
key. This library takes a secret key, and a secret key must never reach a page.
|
|
133
|
+
|
|
134
|
+
## Correcting a typo
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
if not (result := locio.resolve(typed)):
|
|
138
|
+
for near in locio.similar(typed, limit=5):
|
|
139
|
+
print(near.formatted)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Three units, because it scores rows by similarity rather than seeking an
|
|
143
|
+
index. Call it once on an address that failed to resolve, never per keystroke.
|
|
144
|
+
|
|
145
|
+
## Reading an id back
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from locio import NotFound
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
a = locio.get("GAVIC425624910")
|
|
152
|
+
except NotFound:
|
|
153
|
+
... # G-NAF retires ids between releases; search for it again
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Two ids, and which to store
|
|
157
|
+
|
|
158
|
+
A record can carry two pids and they mean different things:
|
|
159
|
+
|
|
160
|
+
| Attribute | Means |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `address_detail_pid` | **This address.** The one to store. |
|
|
163
|
+
| `gnaf.primary_pid` | The **parcel** it sits on, when this row is a unit. |
|
|
164
|
+
|
|
165
|
+
`address.is_unit` reports which you have. Storing the primary pid stores the
|
|
166
|
+
building rather than the door, and nothing about the value itself says so.
|
|
167
|
+
|
|
168
|
+
## Errors
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from locio import LocioError, NotFound, AuthError
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
locio.search("90 bay road")
|
|
175
|
+
except AuthError as err:
|
|
176
|
+
print(err.status, err.title, err.detail)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The API writes refusals for a person to read and they are carried through,
|
|
180
|
+
because the detail is the part that says what to do.
|
|
181
|
+
|
|
182
|
+
## Keys and safety
|
|
183
|
+
|
|
184
|
+
Secret keys (`lc_live_...`) belong on a server. Get one at
|
|
185
|
+
[locio.com.au/account/api](https://locio.com.au/account/api).
|
|
186
|
+
|
|
187
|
+
The client refuses a plaintext `http://` base URL to any host but loopback: a
|
|
188
|
+
bearer key sent in the clear is a key given away.
|
|
189
|
+
|
|
190
|
+
## What it costs
|
|
191
|
+
|
|
192
|
+
`search`, `resolve` and `get` are one unit each, `similar` is three. See
|
|
193
|
+
[locio.com.au/pricing](https://locio.com.au/pricing/).
|
|
194
|
+
|
|
195
|
+
## Licence
|
|
196
|
+
|
|
197
|
+
MIT. Address data is G-NAF, published by Geoscape Australia under CC BY 4.0;
|
|
198
|
+
attribution belongs wherever you show it.
|
locio-0.1.0/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# locio
|
|
2
|
+
|
|
3
|
+
**Australian address validation, address autocomplete and address geocoding**
|
|
4
|
+
in Python, from G-NAF, the national address register.
|
|
5
|
+
|
|
6
|
+
One call gives you a stable G-NAF id, a coordinate, an ABS mesh block and the
|
|
7
|
+
address split into fields. Comparable services bill validate, geocode and
|
|
8
|
+
meshblock separately; here they are fields of one response.
|
|
9
|
+
|
|
10
|
+
No dependencies. Python 3.9+.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pip install locio
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Address validation
|
|
17
|
+
|
|
18
|
+
`resolve` takes an address however you hold it and tells you whether it is
|
|
19
|
+
real, where it is, and what it is made of.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from locio import Locio
|
|
23
|
+
|
|
24
|
+
locio = Locio("lc_live_...")
|
|
25
|
+
|
|
26
|
+
result = locio.resolve("1 george st sydenham nsw 2044")
|
|
27
|
+
|
|
28
|
+
if result.matched:
|
|
29
|
+
a = result.address
|
|
30
|
+
print(a.formatted) # 1 George Street, Sydenham NSW 2044
|
|
31
|
+
print(a.address_detail_pid) # store this, not the text
|
|
32
|
+
print(a.lat, a.lng) # geocoded
|
|
33
|
+
print(a.mesh_block) # ABS mesh block
|
|
34
|
+
print(a.components.postcode) # parsed
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`matched` is `False` for an address that is not in G-NAF. That is an ordinary
|
|
38
|
+
answer, not an error, and it is what a validation call is asking. `Resolution`
|
|
39
|
+
is falsy when nothing matched, so this reads the way you want:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
if not locio.resolve(typed):
|
|
43
|
+
... # ask the customer to check it
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Cleaning a spreadsheet
|
|
47
|
+
|
|
48
|
+
The commonest thing this library gets asked to do. `to_dict()` flattens a
|
|
49
|
+
record to one level so it goes straight into a `DictWriter`.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import csv
|
|
53
|
+
from locio import Locio
|
|
54
|
+
|
|
55
|
+
locio = Locio("lc_live_...")
|
|
56
|
+
|
|
57
|
+
with open("customers.csv") as f, open("clean.csv", "w", newline="") as out:
|
|
58
|
+
rows = list(csv.DictReader(f))
|
|
59
|
+
writer = None
|
|
60
|
+
|
|
61
|
+
for row in rows:
|
|
62
|
+
result = locio.resolve(row["address"])
|
|
63
|
+
clean = result.address.to_dict() if result.matched else {}
|
|
64
|
+
record = {**row, **clean, "matched": result.matched}
|
|
65
|
+
|
|
66
|
+
if writer is None:
|
|
67
|
+
writer = csv.DictWriter(out, fieldnames=list(record))
|
|
68
|
+
writer.writeheader()
|
|
69
|
+
writer.writerow(record)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`resolve_many` does the same sequentially, which is deliberate: the quota is
|
|
73
|
+
per key, and firing a thousand requests at once is how a free tier is spent in
|
|
74
|
+
a second.
|
|
75
|
+
|
|
76
|
+
## Address autocomplete
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
for a in locio.search("104/119 turner", limit=8):
|
|
80
|
+
print(a.formatted, a.address_detail_pid)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For a browser autocomplete use [`@locio-au/react`](https://www.npmjs.com/package/@locio-au/react)
|
|
84
|
+
or [`@locio-au/vue`](https://www.npmjs.com/package/@locio-au/vue) with a **public**
|
|
85
|
+
key. This library takes a secret key, and a secret key must never reach a page.
|
|
86
|
+
|
|
87
|
+
## Correcting a typo
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
if not (result := locio.resolve(typed)):
|
|
91
|
+
for near in locio.similar(typed, limit=5):
|
|
92
|
+
print(near.formatted)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Three units, because it scores rows by similarity rather than seeking an
|
|
96
|
+
index. Call it once on an address that failed to resolve, never per keystroke.
|
|
97
|
+
|
|
98
|
+
## Reading an id back
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from locio import NotFound
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
a = locio.get("GAVIC425624910")
|
|
105
|
+
except NotFound:
|
|
106
|
+
... # G-NAF retires ids between releases; search for it again
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Two ids, and which to store
|
|
110
|
+
|
|
111
|
+
A record can carry two pids and they mean different things:
|
|
112
|
+
|
|
113
|
+
| Attribute | Means |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `address_detail_pid` | **This address.** The one to store. |
|
|
116
|
+
| `gnaf.primary_pid` | The **parcel** it sits on, when this row is a unit. |
|
|
117
|
+
|
|
118
|
+
`address.is_unit` reports which you have. Storing the primary pid stores the
|
|
119
|
+
building rather than the door, and nothing about the value itself says so.
|
|
120
|
+
|
|
121
|
+
## Errors
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from locio import LocioError, NotFound, AuthError
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
locio.search("90 bay road")
|
|
128
|
+
except AuthError as err:
|
|
129
|
+
print(err.status, err.title, err.detail)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The API writes refusals for a person to read and they are carried through,
|
|
133
|
+
because the detail is the part that says what to do.
|
|
134
|
+
|
|
135
|
+
## Keys and safety
|
|
136
|
+
|
|
137
|
+
Secret keys (`lc_live_...`) belong on a server. Get one at
|
|
138
|
+
[locio.com.au/account/api](https://locio.com.au/account/api).
|
|
139
|
+
|
|
140
|
+
The client refuses a plaintext `http://` base URL to any host but loopback: a
|
|
141
|
+
bearer key sent in the clear is a key given away.
|
|
142
|
+
|
|
143
|
+
## What it costs
|
|
144
|
+
|
|
145
|
+
`search`, `resolve` and `get` are one unit each, `similar` is three. See
|
|
146
|
+
[locio.com.au/pricing](https://locio.com.au/pricing/).
|
|
147
|
+
|
|
148
|
+
## Licence
|
|
149
|
+
|
|
150
|
+
MIT. Address data is G-NAF, published by Geoscape Australia under CC BY 4.0;
|
|
151
|
+
attribution belongs wherever you show it.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "locio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Australian address autocomplete, validation and geocoding from G-NAF"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Locio" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"address validation",
|
|
15
|
+
"address autocomplete",
|
|
16
|
+
"address geocoding",
|
|
17
|
+
"address validation service",
|
|
18
|
+
"australian address api",
|
|
19
|
+
"gnaf",
|
|
20
|
+
"g-naf",
|
|
21
|
+
"geocoding",
|
|
22
|
+
"australia",
|
|
23
|
+
"postcode",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.9",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
37
|
+
"Typing :: Typed",
|
|
38
|
+
]
|
|
39
|
+
# No dependencies, on purpose. The whole client is one urllib call and a
|
|
40
|
+
# dataclass; asking for requests would make this the heaviest thing in
|
|
41
|
+
# somebody's lambda for no gain.
|
|
42
|
+
dependencies = []
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://locio.com.au"
|
|
46
|
+
Documentation = "https://locio.com.au/docs/"
|
|
47
|
+
Source = "https://github.com/locio-au/locio-python"
|
|
48
|
+
Changelog = "https://github.com/locio-au/locio-python/blob/main/CHANGELOG.md"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/locio"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Locio: Australian address autocomplete, validation and geocoding.
|
|
2
|
+
|
|
3
|
+
Resolve an Australian address to a stable G-NAF id, a coordinate, an ABS mesh
|
|
4
|
+
block and structured components, from the national address register.
|
|
5
|
+
|
|
6
|
+
from locio import Locio
|
|
7
|
+
|
|
8
|
+
locio = Locio("lc_live_...")
|
|
9
|
+
|
|
10
|
+
result = locio.resolve("1 george st sydenham nsw 2044")
|
|
11
|
+
if result.matched:
|
|
12
|
+
print(result.address.formatted)
|
|
13
|
+
print(result.address.address_detail_pid) # store this, not the text
|
|
14
|
+
print(result.address.lat, result.address.lng)
|
|
15
|
+
|
|
16
|
+
Keys: this library takes a **secret** key and belongs on a server. For browser
|
|
17
|
+
code use a public key with an origin allow list, through ``@locio-au/react`` or
|
|
18
|
+
``@locio-au/vue``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from ._client import DEFAULT_BASE_URL, Locio, __version__
|
|
22
|
+
from ._errors import AuthError, LocioError, NotFound
|
|
23
|
+
from ._models import Address, Components, Record, Resolution
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Locio",
|
|
27
|
+
"Address",
|
|
28
|
+
"Components",
|
|
29
|
+
"Record",
|
|
30
|
+
"Resolution",
|
|
31
|
+
"LocioError",
|
|
32
|
+
"NotFound",
|
|
33
|
+
"AuthError",
|
|
34
|
+
"DEFAULT_BASE_URL",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The client itself."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any, Callable, Iterable, Sequence
|
|
11
|
+
|
|
12
|
+
from ._errors import LocioError, error_for
|
|
13
|
+
from ._models import Address, Resolution
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.locio.com.au"
|
|
18
|
+
|
|
19
|
+
#: (method, url, headers, timeout) -> (status, body bytes)
|
|
20
|
+
Transport = Callable[[str, str, dict, float], "tuple[int, bytes]"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _urllib_transport(method: str, url: str, headers: dict, timeout: float):
|
|
24
|
+
req = urllib.request.Request(url, method=method, headers=headers)
|
|
25
|
+
try:
|
|
26
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
27
|
+
# Bounded: a client should not be talked into reading an unbounded
|
|
28
|
+
# body by whatever is on the other end of the socket.
|
|
29
|
+
return res.status, res.read(8 << 20)
|
|
30
|
+
except urllib.error.HTTPError as err:
|
|
31
|
+
return err.code, err.read(8 << 20)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_base_url(raw: str) -> str:
|
|
35
|
+
"""Refuse anything that would send the key somewhere it should not go.
|
|
36
|
+
|
|
37
|
+
The key is a bearer credential: whoever receives it can spend it. The base
|
|
38
|
+
URL is the one part of this client a caller supplies, so it is the one
|
|
39
|
+
place a mistake or an injected value could redirect the credential. https
|
|
40
|
+
always, and plaintext http only to loopback, which is what a local proxy
|
|
41
|
+
and a test server need.
|
|
42
|
+
"""
|
|
43
|
+
if not raw:
|
|
44
|
+
raise ValueError("locio: no base URL")
|
|
45
|
+
|
|
46
|
+
parsed = urllib.parse.urlparse(raw)
|
|
47
|
+
if not parsed.scheme or not parsed.hostname:
|
|
48
|
+
raise ValueError(f"locio: base URL {raw!r} is not absolute")
|
|
49
|
+
|
|
50
|
+
if parsed.scheme == "https":
|
|
51
|
+
return raw.rstrip("/")
|
|
52
|
+
|
|
53
|
+
if parsed.scheme == "http" and _is_loopback(parsed.hostname):
|
|
54
|
+
return raw.rstrip("/")
|
|
55
|
+
|
|
56
|
+
if parsed.scheme == "http":
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"locio: base URL {raw!r} is plaintext http to a public host, "
|
|
59
|
+
"which would send the key in the clear"
|
|
60
|
+
)
|
|
61
|
+
raise ValueError(f"locio: base URL {raw!r} has scheme {parsed.scheme!r}, want https")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_loopback(host: str) -> bool:
|
|
65
|
+
if host == "localhost":
|
|
66
|
+
return True
|
|
67
|
+
try:
|
|
68
|
+
return ipaddress.ip_address(host).is_loopback
|
|
69
|
+
except ValueError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Locio:
|
|
74
|
+
"""A client for the Locio Australian address API.
|
|
75
|
+
|
|
76
|
+
Address autocomplete, address validation and address geocoding against
|
|
77
|
+
G-NAF, the national address register.
|
|
78
|
+
|
|
79
|
+
>>> from locio import Locio
|
|
80
|
+
>>> locio = Locio("lc_live_...")
|
|
81
|
+
>>> result = locio.resolve("1 george st sydenham nsw 2044")
|
|
82
|
+
>>> result.matched
|
|
83
|
+
True
|
|
84
|
+
>>> result.address.address_detail_pid
|
|
85
|
+
'GANSW...'
|
|
86
|
+
|
|
87
|
+
Takes a **secret** key (``lc_live_...``), which belongs on a server. A
|
|
88
|
+
secret key must never reach a browser: anyone who can read the page can
|
|
89
|
+
read the key and spend the quota. For browser code use a public key
|
|
90
|
+
(``lc_pub_...``) with an origin allow list, through ``@locio-au/react`` or
|
|
91
|
+
``@locio-au/vue``.
|
|
92
|
+
|
|
93
|
+
Thread safe. Make one and keep it.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
api_key: str,
|
|
99
|
+
*,
|
|
100
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
101
|
+
timeout: float = 15.0,
|
|
102
|
+
transport: Transport | None = None,
|
|
103
|
+
):
|
|
104
|
+
if not api_key:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"locio: no API key. Get one at https://locio.com.au/account/api"
|
|
107
|
+
)
|
|
108
|
+
self._key = api_key
|
|
109
|
+
self._base_url = _check_base_url(base_url)
|
|
110
|
+
self._timeout = timeout
|
|
111
|
+
self._transport = transport or _urllib_transport
|
|
112
|
+
|
|
113
|
+
# -- the four calls -------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def search(self, term: str, limit: int | None = None) -> list[Address]:
|
|
116
|
+
"""Candidate addresses for what somebody has typed so far.
|
|
117
|
+
|
|
118
|
+
The **address autocomplete** call: a prefix match down the street
|
|
119
|
+
line, number first then the street, narrowed by a suburb or postcode
|
|
120
|
+
when the term carries one. One unit.
|
|
121
|
+
|
|
122
|
+
Building a browser autocomplete? Use ``@locio-au/react`` or ``@locio-au/vue``
|
|
123
|
+
with a public key rather than putting this key in a page.
|
|
124
|
+
"""
|
|
125
|
+
body = self._get("/v1/addresses", {"q": term, "limit": limit})
|
|
126
|
+
return [Address.from_json(a) for a in body.get("data") or []]
|
|
127
|
+
|
|
128
|
+
def get(self, pid: str) -> Address:
|
|
129
|
+
"""Read one address back by its G-NAF Address Detail PID.
|
|
130
|
+
|
|
131
|
+
Raises :class:`NotFound` when the pid is not in the current release.
|
|
132
|
+
That is worth catching rather than treating as a failure: G-NAF
|
|
133
|
+
retires ids between quarterly releases.
|
|
134
|
+
"""
|
|
135
|
+
body = self._get(f"/v1/addresses/{urllib.parse.quote(pid)}", None)
|
|
136
|
+
return Address.from_json(body.get("data") or {})
|
|
137
|
+
|
|
138
|
+
def resolve(self, address: str) -> Resolution:
|
|
139
|
+
"""Turn a whole address string into one record.
|
|
140
|
+
|
|
141
|
+
The **address validation** call, and it geocodes and parses in the
|
|
142
|
+
same answer rather than charging for three. Write the address however
|
|
143
|
+
you hold it: abbreviations, missing commas and a missing state are all
|
|
144
|
+
fine.
|
|
145
|
+
|
|
146
|
+
``matched`` false is an ordinary answer, not an error. Try
|
|
147
|
+
:meth:`similar` when a typo is likely.
|
|
148
|
+
"""
|
|
149
|
+
body = self._get("/v1/addresses/resolve", {"q": address})
|
|
150
|
+
data = body.get("data") or {}
|
|
151
|
+
found = data.get("address")
|
|
152
|
+
return Resolution(
|
|
153
|
+
matched=bool(data.get("matched")),
|
|
154
|
+
address=Address.from_json(found) if found else None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def similar(self, address: str, limit: int | None = None) -> list[Address]:
|
|
158
|
+
"""Match an address that is not quite right.
|
|
159
|
+
|
|
160
|
+
For when :meth:`resolve` said no and a misspelling is likely: "1 gorge
|
|
161
|
+
rd sydenhum" is one address, and this says which.
|
|
162
|
+
|
|
163
|
+
Three units, because it scores rows by similarity rather than seeking
|
|
164
|
+
an index. Call it once on an address that failed to resolve, never on
|
|
165
|
+
a keystroke.
|
|
166
|
+
"""
|
|
167
|
+
body = self._get("/v1/addresses/similar", {"q": address, "limit": limit})
|
|
168
|
+
return [Address.from_json(a) for a in body.get("data") or []]
|
|
169
|
+
|
|
170
|
+
# -- convenience -----------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def resolve_many(self, addresses: Iterable[str]) -> "list[Resolution]":
|
|
173
|
+
"""Resolve a column of addresses, in order.
|
|
174
|
+
|
|
175
|
+
Sequential on purpose: the quota is per key, and firing a thousand
|
|
176
|
+
requests at once is how a free tier is spent in a second and a shared
|
|
177
|
+
key starves everybody else on it.
|
|
178
|
+
"""
|
|
179
|
+
return [self.resolve(a) for a in addresses]
|
|
180
|
+
|
|
181
|
+
# -- plumbing --------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _get(self, path: str, params: dict | None) -> dict:
|
|
184
|
+
url = self._base_url + path
|
|
185
|
+
query = {k: v for k, v in (params or {}).items() if v not in (None, "", 0)}
|
|
186
|
+
if query:
|
|
187
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
188
|
+
|
|
189
|
+
status, raw = self._transport(
|
|
190
|
+
"GET",
|
|
191
|
+
url,
|
|
192
|
+
{
|
|
193
|
+
"Authorization": f"Bearer {self._key}",
|
|
194
|
+
"Accept": "application/json",
|
|
195
|
+
"User-Agent": f"locio-python/{__version__}",
|
|
196
|
+
},
|
|
197
|
+
self._timeout,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
body = json.loads(raw or b"{}")
|
|
202
|
+
except ValueError:
|
|
203
|
+
body = {}
|
|
204
|
+
|
|
205
|
+
if not 200 <= status < 300:
|
|
206
|
+
raise error_for(status, body.get("title", ""), body.get("detail", ""))
|
|
207
|
+
return body
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What the API said when it refused."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LocioError(Exception):
|
|
7
|
+
"""The API refused, and said why.
|
|
8
|
+
|
|
9
|
+
The service writes a title and a detail meant for a person to read and act
|
|
10
|
+
on: which key is wrong, what a public key may call, how to narrow a search
|
|
11
|
+
that timed out. Collapsing that into "HTTP 403" throws away the only part
|
|
12
|
+
of the answer that says what to do about it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, title: str = "", detail: str = ""):
|
|
16
|
+
self.status = status
|
|
17
|
+
self.title = title
|
|
18
|
+
self.detail = detail
|
|
19
|
+
message = title or f"HTTP {status}"
|
|
20
|
+
if detail:
|
|
21
|
+
message = f"{message}: {detail}"
|
|
22
|
+
super().__init__(f"{status} {message}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFound(LocioError):
|
|
26
|
+
"""No address with that id in the current G-NAF release.
|
|
27
|
+
|
|
28
|
+
Its own class because it is an ordinary thing to hit rather than a
|
|
29
|
+
failure: G-NAF retires ids between quarterly releases, so a caller storing
|
|
30
|
+
pids will meet this eventually and needs to tell it apart from a key
|
|
31
|
+
problem or an outage.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthError(LocioError):
|
|
36
|
+
"""The key was refused: missing, wrong, or not allowed from this origin."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def error_for(status: int, title: str = "", detail: str = "") -> LocioError:
|
|
40
|
+
if status == 404:
|
|
41
|
+
return NotFound(status, title, detail)
|
|
42
|
+
if status in (401, 403):
|
|
43
|
+
return AuthError(status, title, detail)
|
|
44
|
+
return LocioError(status, title, detail)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The shapes the API returns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _only_known(cls, raw: Mapping[str, Any]) -> dict:
|
|
11
|
+
"""Keep the fields this class knows about.
|
|
12
|
+
|
|
13
|
+
The API may add a field before this library knows the name, and a client
|
|
14
|
+
that raises on an unrecognised key turns a compatible change at the
|
|
15
|
+
service into an outage in somebody's nightly job.
|
|
16
|
+
"""
|
|
17
|
+
known = {f.name for f in fields(cls)}
|
|
18
|
+
return {k: v for k, v in (raw or {}).items() if k in known}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Components:
|
|
23
|
+
"""A G-NAF address split into its published fields, which is what a form
|
|
24
|
+
has boxes for."""
|
|
25
|
+
|
|
26
|
+
building_name: str | None = None
|
|
27
|
+
|
|
28
|
+
flat_type: str | None = None
|
|
29
|
+
flat_number: str | None = None
|
|
30
|
+
level_type: str | None = None
|
|
31
|
+
level_number: str | None = None
|
|
32
|
+
|
|
33
|
+
#: The only number an address on a new subdivision has, before street
|
|
34
|
+
#: numbers are assigned.
|
|
35
|
+
lot_number: str | None = None
|
|
36
|
+
|
|
37
|
+
number_first: str | None = None
|
|
38
|
+
number_first_suffix: str | None = None
|
|
39
|
+
number_last: str | None = None
|
|
40
|
+
number_last_suffix: str | None = None
|
|
41
|
+
|
|
42
|
+
street_name: str | None = None
|
|
43
|
+
street_type: str | None = None
|
|
44
|
+
street_suffix: str | None = None
|
|
45
|
+
|
|
46
|
+
locality_name: str | None = None
|
|
47
|
+
state: str | None = None
|
|
48
|
+
postcode: str | None = None
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_json(cls, raw: Mapping[str, Any] | None) -> "Components":
|
|
52
|
+
return cls(**_only_known(cls, raw))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Record:
|
|
57
|
+
"""The part of a G-NAF row that is not the address itself.
|
|
58
|
+
|
|
59
|
+
Values are carried as G-NAF publishes them, upper case included:
|
|
60
|
+
``PRINCIPAL``, ``SECONDARY``, ``PROPERTY ACCESS POINT SETBACK``. They are
|
|
61
|
+
codes to match on rather than prose to print.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
#: When G-NAF first published this address.
|
|
65
|
+
date_created: date | None = None
|
|
66
|
+
|
|
67
|
+
#: G-NAF's name for the site, where the address is part of a named one:
|
|
68
|
+
#: a school, a hospital, a caravan park.
|
|
69
|
+
address_site_name: str | None = None
|
|
70
|
+
|
|
71
|
+
#: The title reference for the parcel, and the join to a land registry.
|
|
72
|
+
legal_parcel_id: str | None = None
|
|
73
|
+
|
|
74
|
+
#: PRINCIPAL or ALIAS.
|
|
75
|
+
alias_principal: str | None = None
|
|
76
|
+
|
|
77
|
+
#: PRIMARY or SECONDARY.
|
|
78
|
+
primary_secondary: str | None = None
|
|
79
|
+
|
|
80
|
+
#: The parcel this address sits on, when this row is a unit under one.
|
|
81
|
+
#:
|
|
82
|
+
#: Emphatically not the address id. Storing this instead of
|
|
83
|
+
#: ``address_detail_pid`` stores the building rather than the door.
|
|
84
|
+
primary_pid: str | None = None
|
|
85
|
+
|
|
86
|
+
#: How the coordinate was derived: the difference between a pin on the
|
|
87
|
+
#: door and a pin in the middle of a paddock.
|
|
88
|
+
geocode_type: str | None = None
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_json(cls, raw: Mapping[str, Any] | None) -> "Record":
|
|
92
|
+
data = _only_known(cls, raw)
|
|
93
|
+
created = data.get("date_created")
|
|
94
|
+
if isinstance(created, str) and created:
|
|
95
|
+
# A date that will not parse is one field of one row. Dropping it
|
|
96
|
+
# is better than refusing an address that is otherwise complete.
|
|
97
|
+
try:
|
|
98
|
+
data["date_created"] = datetime.fromisoformat(
|
|
99
|
+
created.replace("Z", "+00:00")
|
|
100
|
+
).date()
|
|
101
|
+
except ValueError:
|
|
102
|
+
data["date_created"] = None
|
|
103
|
+
return cls(**data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Address:
|
|
108
|
+
"""One resolved G-NAF record."""
|
|
109
|
+
|
|
110
|
+
#: The G-NAF Address Detail PID: **this** address, and the id to store
|
|
111
|
+
#: against your own record. Stable across G-NAF releases for an address
|
|
112
|
+
#: that has not changed.
|
|
113
|
+
#:
|
|
114
|
+
#: Not to be confused with ``gnaf.primary_pid``, which identifies the
|
|
115
|
+
#: parcel this address sits on.
|
|
116
|
+
address_detail_pid: str = ""
|
|
117
|
+
|
|
118
|
+
#: The address on one line, as an envelope would write it.
|
|
119
|
+
formatted: str = ""
|
|
120
|
+
|
|
121
|
+
lat: float | None = None
|
|
122
|
+
lng: float | None = None
|
|
123
|
+
|
|
124
|
+
#: The ABS mesh block: the smallest unit the census publishes, and the
|
|
125
|
+
#: join key to every ABS statistic about the area.
|
|
126
|
+
mesh_block: str | None = None
|
|
127
|
+
|
|
128
|
+
components: Components = field(default_factory=Components)
|
|
129
|
+
gnaf: Record = field(default_factory=Record)
|
|
130
|
+
|
|
131
|
+
#: Where it sits, as names. Empty for an address outside the areas seeded
|
|
132
|
+
#: so far, which is an ordinary thing for a good address to be.
|
|
133
|
+
country: str | None = None
|
|
134
|
+
region: str | None = None
|
|
135
|
+
locality: str | None = None
|
|
136
|
+
|
|
137
|
+
#: Deprecated former name for :attr:`address_detail_pid`, same value,
|
|
138
|
+
#: carried while the API emits both.
|
|
139
|
+
gnaf_pid: str | None = None
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_json(cls, raw: Mapping[str, Any]) -> "Address":
|
|
143
|
+
data = _only_known(cls, raw)
|
|
144
|
+
data["components"] = Components.from_json(raw.get("components"))
|
|
145
|
+
data["gnaf"] = Record.from_json(raw.get("gnaf"))
|
|
146
|
+
return cls(**data)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def is_unit(self) -> bool:
|
|
150
|
+
"""Whether this address is a unit under a parcel, in which case
|
|
151
|
+
``gnaf.primary_pid`` names the parcel."""
|
|
152
|
+
return (self.gnaf.primary_secondary or "").upper() == "SECONDARY"
|
|
153
|
+
|
|
154
|
+
def to_dict(self) -> dict:
|
|
155
|
+
"""Flatten to one level, for writing straight into a csv.DictWriter.
|
|
156
|
+
|
|
157
|
+
Cleaning a spreadsheet of addresses is the commonest thing this
|
|
158
|
+
library is asked to do, and a nested record means every caller writes
|
|
159
|
+
the same unpacking loop.
|
|
160
|
+
"""
|
|
161
|
+
out = {
|
|
162
|
+
"address_detail_pid": self.address_detail_pid,
|
|
163
|
+
"formatted": self.formatted,
|
|
164
|
+
"lat": self.lat,
|
|
165
|
+
"lng": self.lng,
|
|
166
|
+
"mesh_block": self.mesh_block,
|
|
167
|
+
"country": self.country,
|
|
168
|
+
"region": self.region,
|
|
169
|
+
"locality": self.locality,
|
|
170
|
+
}
|
|
171
|
+
out.update({k: v for k, v in asdict(self.components).items() if v is not None})
|
|
172
|
+
out.update(
|
|
173
|
+
{
|
|
174
|
+
f"gnaf_{k}": (v.isoformat() if isinstance(v, date) else v)
|
|
175
|
+
for k, v in asdict(self.gnaf).items()
|
|
176
|
+
if v is not None
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class Resolution:
|
|
184
|
+
"""The answer to a validation call: whether the address is real, and what
|
|
185
|
+
it resolves to when it is.
|
|
186
|
+
|
|
187
|
+
Falsy when nothing matched, so ``if not client.resolve(...)`` reads the
|
|
188
|
+
way it should.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
matched: bool = False
|
|
192
|
+
address: Address | None = None
|
|
193
|
+
|
|
194
|
+
def __bool__(self) -> bool:
|
|
195
|
+
return self.matched
|
|
File without changes
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The Locio client, tested against the shape the API actually sends.
|
|
2
|
+
|
|
3
|
+
Written on unittest rather than pytest so the suite runs with nothing
|
|
4
|
+
installed: the library itself has no dependencies, and a test suite that needs
|
|
5
|
+
one is a worse first impression than no test suite. pytest runs these too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import unittest
|
|
12
|
+
from datetime import date
|
|
13
|
+
|
|
14
|
+
from locio import Address, Locio, LocioError, NotFound
|
|
15
|
+
|
|
16
|
+
# A real answer, copied rather than invented.
|
|
17
|
+
UNIT = {
|
|
18
|
+
"data": [
|
|
19
|
+
{
|
|
20
|
+
"address_detail_pid": "GAVIC425624910",
|
|
21
|
+
"gnaf_pid": "GAVIC425624910",
|
|
22
|
+
"formatted": "Unit 104, 119 Turner Street, Abbotsford VIC 3067",
|
|
23
|
+
"lat": -37.79928725,
|
|
24
|
+
"lng": 145.0004257,
|
|
25
|
+
"mesh_block": "21301420000",
|
|
26
|
+
"country": "Australia",
|
|
27
|
+
"region": "Victoria",
|
|
28
|
+
"locality": "Abbotsford",
|
|
29
|
+
"components": {
|
|
30
|
+
"flat_type": "Unit",
|
|
31
|
+
"flat_number": "104",
|
|
32
|
+
"number_first": "119",
|
|
33
|
+
"street_name": "Turner",
|
|
34
|
+
"street_type": "Street",
|
|
35
|
+
"locality_name": "Abbotsford",
|
|
36
|
+
"state": "VIC",
|
|
37
|
+
"postcode": "3067",
|
|
38
|
+
},
|
|
39
|
+
"gnaf": {
|
|
40
|
+
"date_created": "2018-07-20T00:00:00Z",
|
|
41
|
+
"legal_parcel_id": "T104\\PS638763",
|
|
42
|
+
"alias_principal": "PRINCIPAL",
|
|
43
|
+
"primary_secondary": "SECONDARY",
|
|
44
|
+
"primary_pid": "GAVIC425580914",
|
|
45
|
+
"geocode_type": "PROPERTY ACCESS POINT SETBACK",
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"generated_at": "2026-09-17T06:35:33Z",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Recorder:
|
|
54
|
+
"""A transport that records the request and returns a canned answer."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, status=200, body=None):
|
|
57
|
+
self.status = status
|
|
58
|
+
self.body = body if body is not None else UNIT
|
|
59
|
+
self.calls = []
|
|
60
|
+
|
|
61
|
+
def __call__(self, method, url, headers, timeout):
|
|
62
|
+
self.calls.append({"method": method, "url": url, "headers": headers})
|
|
63
|
+
return self.status, json.dumps(self.body).encode()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def last_url(self):
|
|
67
|
+
return self.calls[-1]["url"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def client(recorder, key="lc_live_secret", **kw):
|
|
71
|
+
return Locio(key, transport=recorder, **kw)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestSearch(unittest.TestCase):
|
|
75
|
+
def test_decodes_the_whole_record(self):
|
|
76
|
+
rec = Recorder()
|
|
77
|
+
found = client(rec).search("104/119 turner", limit=5)
|
|
78
|
+
|
|
79
|
+
self.assertEqual(len(found), 1)
|
|
80
|
+
a = found[0]
|
|
81
|
+
self.assertIsInstance(a, Address)
|
|
82
|
+
self.assertEqual(a.address_detail_pid, "GAVIC425624910")
|
|
83
|
+
self.assertEqual(a.components.flat_number, "104")
|
|
84
|
+
self.assertEqual(a.components.street_name, "Turner")
|
|
85
|
+
# The parcel is a different address from the door.
|
|
86
|
+
self.assertEqual(a.gnaf.primary_pid, "GAVIC425580914")
|
|
87
|
+
self.assertTrue(a.is_unit)
|
|
88
|
+
self.assertEqual(a.gnaf.date_created, date(2018, 7, 20))
|
|
89
|
+
self.assertEqual(a.mesh_block, "21301420000")
|
|
90
|
+
|
|
91
|
+
self.assertIn("/v1/addresses?", rec.last_url)
|
|
92
|
+
self.assertIn("q=104%2F119+turner", rec.last_url)
|
|
93
|
+
self.assertIn("limit=5", rec.last_url)
|
|
94
|
+
self.assertEqual(rec.calls[-1]["headers"]["Authorization"], "Bearer lc_live_secret")
|
|
95
|
+
self.assertTrue(rec.calls[-1]["headers"]["User-Agent"].startswith("locio-python/"))
|
|
96
|
+
|
|
97
|
+
def test_a_zero_limit_is_omitted(self):
|
|
98
|
+
"""The service's own default is the right one; do not invent a zero."""
|
|
99
|
+
rec = Recorder()
|
|
100
|
+
client(rec).search("90 bay road")
|
|
101
|
+
self.assertNotIn("limit=", rec.last_url)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestResolve(unittest.TestCase):
|
|
105
|
+
def test_reports_whether_it_matched(self):
|
|
106
|
+
rec = Recorder(body={"data": {"matched": False, "address": None}})
|
|
107
|
+
res = client(rec).resolve("1 nowhere st")
|
|
108
|
+
|
|
109
|
+
self.assertFalse(res.matched)
|
|
110
|
+
self.assertIsNone(res.address)
|
|
111
|
+
# Falsy, so `if not client.resolve(...)` reads correctly.
|
|
112
|
+
self.assertFalse(res)
|
|
113
|
+
|
|
114
|
+
def test_returns_the_address_when_it_matched(self):
|
|
115
|
+
rec = Recorder(body={"data": {"matched": True, "address": UNIT["data"][0]}})
|
|
116
|
+
res = client(rec).resolve("104/119 turner st abbotsford")
|
|
117
|
+
|
|
118
|
+
self.assertTrue(res.matched)
|
|
119
|
+
self.assertEqual(res.address.address_detail_pid, "GAVIC425624910")
|
|
120
|
+
self.assertTrue(res)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestErrors(unittest.TestCase):
|
|
124
|
+
def test_a_retired_pid_raises_not_found(self):
|
|
125
|
+
"""G-NAF retires ids between releases. Worth catching by itself."""
|
|
126
|
+
rec = Recorder(status=404, body={"title": "no address with that id"})
|
|
127
|
+
with self.assertRaises(NotFound):
|
|
128
|
+
client(rec).get("GAVIC000000000")
|
|
129
|
+
|
|
130
|
+
def test_an_api_error_keeps_what_the_api_said(self):
|
|
131
|
+
rec = Recorder(
|
|
132
|
+
status=403,
|
|
133
|
+
body={
|
|
134
|
+
"title": "this key is not allowed from here",
|
|
135
|
+
"detail": "A public key only works from the sites listed on it.",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
with self.assertRaises(LocioError) as caught:
|
|
139
|
+
client(rec).search("90 bay road")
|
|
140
|
+
|
|
141
|
+
err = caught.exception
|
|
142
|
+
self.assertEqual(err.status, 403)
|
|
143
|
+
self.assertIn("not allowed from here", str(err))
|
|
144
|
+
self.assertIn("sites listed on it", str(err))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestBaseURLIsACredentialBoundary(unittest.TestCase):
|
|
148
|
+
"""A bearer key sent in the clear is a key given away.
|
|
149
|
+
|
|
150
|
+
The base URL is the one part of this client a caller supplies, so it is
|
|
151
|
+
the one place a mistake could redirect the credential somewhere else.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def test_must_be_https(self):
|
|
155
|
+
for bad in ["", "not a url", "ftp://example.com", "//evil.example",
|
|
156
|
+
"http://example.com"]:
|
|
157
|
+
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
|
158
|
+
Locio("lc_live_x", base_url=bad, transport=Recorder())
|
|
159
|
+
|
|
160
|
+
def test_loopback_http_is_allowed_for_local_development(self):
|
|
161
|
+
Locio("lc_live_x", base_url="http://127.0.0.1:8080", transport=Recorder())
|
|
162
|
+
Locio("lc_live_x", base_url="http://localhost:8080", transport=Recorder())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestSimilar(unittest.TestCase):
|
|
166
|
+
def test_sends_the_limit(self):
|
|
167
|
+
rec = Recorder()
|
|
168
|
+
client(rec).similar("1 gorge rd sydenhum", limit=3)
|
|
169
|
+
self.assertIn("/v1/addresses/similar?", rec.last_url)
|
|
170
|
+
self.assertIn("limit=3", rec.last_url)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TestTolerance(unittest.TestCase):
|
|
174
|
+
def test_a_missing_gnaf_record_does_not_break_decoding(self):
|
|
175
|
+
"""An older record carries no gnaf object. That is not a failure."""
|
|
176
|
+
bare = {k: v for k, v in UNIT["data"][0].items() if k != "gnaf"}
|
|
177
|
+
rec = Recorder(body={"data": [bare]})
|
|
178
|
+
a = client(rec).search("104/119 turner")[0]
|
|
179
|
+
|
|
180
|
+
self.assertIsNone(a.gnaf.primary_pid)
|
|
181
|
+
self.assertFalse(a.is_unit)
|
|
182
|
+
|
|
183
|
+
def test_an_unknown_field_is_ignored_rather_than_fatal(self):
|
|
184
|
+
"""The API may add a field before this library knows about it."""
|
|
185
|
+
extra = dict(UNIT["data"][0], something_new="x")
|
|
186
|
+
rec = Recorder(body={"data": [extra]})
|
|
187
|
+
a = client(rec).search("104/119 turner")[0]
|
|
188
|
+
self.assertEqual(a.address_detail_pid, "GAVIC425624910")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestSpreadsheetUse(unittest.TestCase):
|
|
192
|
+
def test_address_flattens_for_csv_writing(self):
|
|
193
|
+
"""Cleaning a spreadsheet is the commonest server side use, so the
|
|
194
|
+
record has to reach csv.DictWriter without hand unpacking."""
|
|
195
|
+
rec = Recorder()
|
|
196
|
+
a = client(rec).search("104/119 turner")[0]
|
|
197
|
+
row = a.to_dict()
|
|
198
|
+
|
|
199
|
+
self.assertEqual(row["address_detail_pid"], "GAVIC425624910")
|
|
200
|
+
self.assertEqual(row["postcode"], "3067")
|
|
201
|
+
self.assertEqual(row["lat"], -37.79928725)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
unittest.main()
|