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.
@@ -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
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. Search, get, resolve and similar against the Locio address API,
6
+ with no dependencies.
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()