nominatim-reverse-client 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.
- nominatim_reverse_client-0.1.0/LICENSE +21 -0
- nominatim_reverse_client-0.1.0/PKG-INFO +105 -0
- nominatim_reverse_client-0.1.0/README.md +81 -0
- nominatim_reverse_client-0.1.0/pyproject.toml +53 -0
- nominatim_reverse_client-0.1.0/setup.cfg +4 -0
- nominatim_reverse_client-0.1.0/src/nominatim_reverse_client/__init__.py +110 -0
- nominatim_reverse_client-0.1.0/src/nominatim_reverse_client/py.typed +0 -0
- nominatim_reverse_client-0.1.0/src/nominatim_reverse_client.egg-info/PKG-INFO +105 -0
- nominatim_reverse_client-0.1.0/src/nominatim_reverse_client.egg-info/SOURCES.txt +11 -0
- nominatim_reverse_client-0.1.0/src/nominatim_reverse_client.egg-info/dependency_links.txt +1 -0
- nominatim_reverse_client-0.1.0/src/nominatim_reverse_client.egg-info/requires.txt +8 -0
- nominatim_reverse_client-0.1.0/src/nominatim_reverse_client.egg-info/top_level.txt +1 -0
- nominatim_reverse_client-0.1.0/tests/test_client.py +205 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Harrison
|
|
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,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nominatim-reverse-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async reverse geocoding with a caller-owned session and explicit provider
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Source, https://github.com/jouwdan/ha-pleos/tree/main/packages/nominatim-reverse-client
|
|
7
|
+
Project-URL: Issues, https://github.com/jouwdan/ha-pleos/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jouwdan/ha-pleos/blob/main/packages/nominatim-reverse-client/CHANGELOG.md
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: aiohttp>=3.9.5
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
19
|
+
Requires-Dist: coverage>=7; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.11; extra == "dev"
|
|
22
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# nominatim-reverse-client
|
|
26
|
+
|
|
27
|
+
An asynchronous Python client for reverse lookup with an explicitly selected Nominatim-compatible provider. It has no Home Assistant dependency and does not select a public provider, cache results, round coordinates, or start background requests.
|
|
28
|
+
|
|
29
|
+
Requires Python 3.11 or later and a caller-owned `aiohttp.ClientSession`.
|
|
30
|
+
|
|
31
|
+
Once the release gate below is complete, install the published distribution with `python -m pip install nominatim-reverse-client`.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import aiohttp
|
|
37
|
+
|
|
38
|
+
from nominatim_reverse_client import GeocodingError, NominatimClient
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def lookup(service_url: str, latitude: float, longitude: float):
|
|
42
|
+
async with aiohttp.ClientSession() as session:
|
|
43
|
+
client = NominatimClient(
|
|
44
|
+
session,
|
|
45
|
+
service_url,
|
|
46
|
+
user_agent="MyApplication (https://example.com/contact)",
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
address = await client.reverse(latitude, longitude)
|
|
50
|
+
except GeocodingError as error:
|
|
51
|
+
# error.code contains no provider URL, API key or coordinates.
|
|
52
|
+
return error.code
|
|
53
|
+
return address
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`reverse()` returns `Address(display_name, attribution)`. The display name is stripped of surrounding whitespace but is otherwise unchanged. Attribution is taken from `attribution`, `licence`, or `license` in the provider's response. Display the attribution and follow the provider's usage and caching terms.
|
|
57
|
+
|
|
58
|
+
Pass a base URL or the provider's `/reverse` endpoint. `reverse_url(value)` returns its normalized endpoint, or an empty string when it is missing or invalid. URLs must use HTTP or HTTPS and cannot contain login credentials, query parameters, or fragments. HTTPS protects coordinates and API keys in transit; HTTP is available for providers operated on a trusted local network.
|
|
59
|
+
|
|
60
|
+
If the selected provider requires a key, pass `api_key="..."`; it is sent using the `key` query parameter. Every lookup also sends `lat`, `lon`, `format=json`, and `addressdetails=1`. Only valid numeric WGS84 coordinates are accepted. Requests use a ten-second timeout and never follow redirects or retry automatically. The client never closes the caller's session.
|
|
61
|
+
|
|
62
|
+
The caller is responsible for obtaining consent before sending coordinates, choosing a provider, respecting its request limits, caching or rounding as appropriate, and protecting addresses in local history. This client logs nothing. Exceptions contain a code only; provider responses and underlying request exceptions are not included in their displayed traceback.
|
|
63
|
+
|
|
64
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
`GeocodingError.code` is one of:
|
|
67
|
+
|
|
68
|
+
- `service_url_missing`: no valid explicit endpoint was supplied.
|
|
69
|
+
- `invalid_coordinates`: coordinates are missing, nonnumeric, nonfinite, or outside WGS84 bounds.
|
|
70
|
+
- `http_<status>`: the provider returned a non-200 status, including redirects and rate limits.
|
|
71
|
+
- `timeout` or `connection_error`: the request could not complete.
|
|
72
|
+
- `invalid_json`, `invalid_response`, or `no_display_name`: the provider response could not supply an address.
|
|
73
|
+
|
|
74
|
+
Cancellation propagates to the caller. Provider errors are not retried; callers decide when another request is appropriate.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
From this package directory:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
python -m pip install -e '.[dev]'
|
|
82
|
+
python -m ruff check .
|
|
83
|
+
python -m ruff format --check .
|
|
84
|
+
python -m mypy
|
|
85
|
+
python -m coverage run -m unittest discover -s tests -v
|
|
86
|
+
python -m coverage report
|
|
87
|
+
python -m build
|
|
88
|
+
python -m twine check dist/*
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Tests use synthetic responses and never contact a provider. The public `publish-geocoder.yml` workflow validates Python 3.11 and 3.14 and builds both a source distribution and wheel.
|
|
92
|
+
|
|
93
|
+
## Release gate
|
|
94
|
+
|
|
95
|
+
The distribution is maintained in this public repository independently of the Home Assistant integration and `pleos-client`. A package release must be published before an integration release pins it.
|
|
96
|
+
|
|
97
|
+
Before the first publication, a maintainer must register or reserve the `nominatim-reverse-client` project on PyPI and configure a trusted publisher for repository `jouwdan/ha-pleos`, workflow `publish-geocoder.yml`, and environment `pypi-nominatim-reverse-client`. Configure that GitHub environment with required maintainer approval. This account setup cannot be provided by repository code.
|
|
98
|
+
|
|
99
|
+
After checks and review, tag the approved commit `nominatim-reverse-client-v<version>` matching this package's `pyproject.toml`, then push that tag. The workflow checks the tag/version match, builds the distributions in public CI, and uses OIDC to publish those same artifacts after environment approval. Ordinary integration tags, branch pushes, pull requests, and manual workflow runs cannot publish this distribution. No PyPI token is stored in the repository.
|
|
100
|
+
|
|
101
|
+
Do not create a GitHub Release for the package tag: GitHub Releases in this repository belong to the Home Assistant integration and are consumed by HACS. Package provenance is the namespaced Git tag and its public build/publish workflow run; package changes are recorded in `CHANGELOG.md`.
|
|
102
|
+
|
|
103
|
+
Verify the published wheel and source distribution can be installed before publishing the dependent integration release. Provider credentials or live addresses are not required for release validation.
|
|
104
|
+
|
|
105
|
+
Licensed under the MIT license.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# nominatim-reverse-client
|
|
2
|
+
|
|
3
|
+
An asynchronous Python client for reverse lookup with an explicitly selected Nominatim-compatible provider. It has no Home Assistant dependency and does not select a public provider, cache results, round coordinates, or start background requests.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.11 or later and a caller-owned `aiohttp.ClientSession`.
|
|
6
|
+
|
|
7
|
+
Once the release gate below is complete, install the published distribution with `python -m pip install nominatim-reverse-client`.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from nominatim_reverse_client import GeocodingError, NominatimClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def lookup(service_url: str, latitude: float, longitude: float):
|
|
18
|
+
async with aiohttp.ClientSession() as session:
|
|
19
|
+
client = NominatimClient(
|
|
20
|
+
session,
|
|
21
|
+
service_url,
|
|
22
|
+
user_agent="MyApplication (https://example.com/contact)",
|
|
23
|
+
)
|
|
24
|
+
try:
|
|
25
|
+
address = await client.reverse(latitude, longitude)
|
|
26
|
+
except GeocodingError as error:
|
|
27
|
+
# error.code contains no provider URL, API key or coordinates.
|
|
28
|
+
return error.code
|
|
29
|
+
return address
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`reverse()` returns `Address(display_name, attribution)`. The display name is stripped of surrounding whitespace but is otherwise unchanged. Attribution is taken from `attribution`, `licence`, or `license` in the provider's response. Display the attribution and follow the provider's usage and caching terms.
|
|
33
|
+
|
|
34
|
+
Pass a base URL or the provider's `/reverse` endpoint. `reverse_url(value)` returns its normalized endpoint, or an empty string when it is missing or invalid. URLs must use HTTP or HTTPS and cannot contain login credentials, query parameters, or fragments. HTTPS protects coordinates and API keys in transit; HTTP is available for providers operated on a trusted local network.
|
|
35
|
+
|
|
36
|
+
If the selected provider requires a key, pass `api_key="..."`; it is sent using the `key` query parameter. Every lookup also sends `lat`, `lon`, `format=json`, and `addressdetails=1`. Only valid numeric WGS84 coordinates are accepted. Requests use a ten-second timeout and never follow redirects or retry automatically. The client never closes the caller's session.
|
|
37
|
+
|
|
38
|
+
The caller is responsible for obtaining consent before sending coordinates, choosing a provider, respecting its request limits, caching or rounding as appropriate, and protecting addresses in local history. This client logs nothing. Exceptions contain a code only; provider responses and underlying request exceptions are not included in their displayed traceback.
|
|
39
|
+
|
|
40
|
+
## Errors
|
|
41
|
+
|
|
42
|
+
`GeocodingError.code` is one of:
|
|
43
|
+
|
|
44
|
+
- `service_url_missing`: no valid explicit endpoint was supplied.
|
|
45
|
+
- `invalid_coordinates`: coordinates are missing, nonnumeric, nonfinite, or outside WGS84 bounds.
|
|
46
|
+
- `http_<status>`: the provider returned a non-200 status, including redirects and rate limits.
|
|
47
|
+
- `timeout` or `connection_error`: the request could not complete.
|
|
48
|
+
- `invalid_json`, `invalid_response`, or `no_display_name`: the provider response could not supply an address.
|
|
49
|
+
|
|
50
|
+
Cancellation propagates to the caller. Provider errors are not retried; callers decide when another request is appropriate.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
From this package directory:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
python -m pip install -e '.[dev]'
|
|
58
|
+
python -m ruff check .
|
|
59
|
+
python -m ruff format --check .
|
|
60
|
+
python -m mypy
|
|
61
|
+
python -m coverage run -m unittest discover -s tests -v
|
|
62
|
+
python -m coverage report
|
|
63
|
+
python -m build
|
|
64
|
+
python -m twine check dist/*
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Tests use synthetic responses and never contact a provider. The public `publish-geocoder.yml` workflow validates Python 3.11 and 3.14 and builds both a source distribution and wheel.
|
|
68
|
+
|
|
69
|
+
## Release gate
|
|
70
|
+
|
|
71
|
+
The distribution is maintained in this public repository independently of the Home Assistant integration and `pleos-client`. A package release must be published before an integration release pins it.
|
|
72
|
+
|
|
73
|
+
Before the first publication, a maintainer must register or reserve the `nominatim-reverse-client` project on PyPI and configure a trusted publisher for repository `jouwdan/ha-pleos`, workflow `publish-geocoder.yml`, and environment `pypi-nominatim-reverse-client`. Configure that GitHub environment with required maintainer approval. This account setup cannot be provided by repository code.
|
|
74
|
+
|
|
75
|
+
After checks and review, tag the approved commit `nominatim-reverse-client-v<version>` matching this package's `pyproject.toml`, then push that tag. The workflow checks the tag/version match, builds the distributions in public CI, and uses OIDC to publish those same artifacts after environment approval. Ordinary integration tags, branch pushes, pull requests, and manual workflow runs cannot publish this distribution. No PyPI token is stored in the repository.
|
|
76
|
+
|
|
77
|
+
Do not create a GitHub Release for the package tag: GitHub Releases in this repository belong to the Home Assistant integration and are consumed by HACS. Package provenance is the namespaced Git tag and its public build/publish workflow run; package changes are recorded in `CHANGELOG.md`.
|
|
78
|
+
|
|
79
|
+
Verify the published wheel and source distribution can be installed before publishing the dependent integration release. Provider credentials or live addresses are not required for release validation.
|
|
80
|
+
|
|
81
|
+
Licensed under the MIT license.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nominatim-reverse-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async reverse geocoding with a caller-owned session and explicit provider"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["aiohttp>=3.9.5"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Source = "https://github.com/jouwdan/ha-pleos/tree/main/packages/nominatim-reverse-client"
|
|
23
|
+
Issues = "https://github.com/jouwdan/ha-pleos/issues"
|
|
24
|
+
Changelog = "https://github.com/jouwdan/ha-pleos/blob/main/packages/nominatim-reverse-client/CHANGELOG.md"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["build>=1", "coverage>=7", "mypy>=1.10", "ruff>=0.11", "twine>=6"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
nominatim_reverse_client = ["py.typed"]
|
|
34
|
+
|
|
35
|
+
[tool.mypy]
|
|
36
|
+
python_version = "3.11"
|
|
37
|
+
strict = true
|
|
38
|
+
files = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.coverage.run]
|
|
41
|
+
branch = true
|
|
42
|
+
source = ["nominatim_reverse_client"]
|
|
43
|
+
|
|
44
|
+
[tool.coverage.report]
|
|
45
|
+
show_missing = true
|
|
46
|
+
fail_under = 100
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
target-version = "py311"
|
|
50
|
+
line-length = 100
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "I", "UP"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Nominatim-compatible reverse lookup without logging private request data."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from math import isfinite
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from aiohttp import ClientError, ClientSession, ClientTimeout, ContentTypeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GeocodingError(Exception):
|
|
11
|
+
"""A provider failure with a public code and no request or response details."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, code: str) -> None:
|
|
14
|
+
super().__init__(code)
|
|
15
|
+
self.code = code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Address:
|
|
20
|
+
"""The provider's address and attribution, without Home Assistant state limits."""
|
|
21
|
+
|
|
22
|
+
display_name: str
|
|
23
|
+
attribution: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def reverse_url(value: str) -> str:
|
|
27
|
+
"""Normalize an explicit provider URL, rejecting credentials and query data."""
|
|
28
|
+
if not isinstance(value, str) or not value.strip():
|
|
29
|
+
return ""
|
|
30
|
+
value = value.strip().rstrip("/")
|
|
31
|
+
try:
|
|
32
|
+
parsed = urlparse(value)
|
|
33
|
+
if (
|
|
34
|
+
parsed.scheme not in ("http", "https")
|
|
35
|
+
or not parsed.hostname
|
|
36
|
+
or parsed.username is not None
|
|
37
|
+
or parsed.password is not None
|
|
38
|
+
or "?" in value
|
|
39
|
+
or "#" in value
|
|
40
|
+
):
|
|
41
|
+
return ""
|
|
42
|
+
parsed.port
|
|
43
|
+
except ValueError:
|
|
44
|
+
return ""
|
|
45
|
+
return value if parsed.path.endswith("/reverse") else value + "/reverse"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NominatimClient:
|
|
49
|
+
"""Read an explicitly selected provider, never following redirects.
|
|
50
|
+
|
|
51
|
+
The caller owns the HTTP session, coordinate rounding, caching and polling.
|
|
52
|
+
No default provider is selected and no requests or private values are logged.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self, session: ClientSession, service_url: str, *, user_agent: str, api_key: str = ""
|
|
57
|
+
) -> None:
|
|
58
|
+
self.session = session
|
|
59
|
+
self.service_url = reverse_url(service_url)
|
|
60
|
+
self.user_agent = user_agent
|
|
61
|
+
self.api_key = api_key.strip()
|
|
62
|
+
|
|
63
|
+
async def reverse(self, latitude: float, longitude: float) -> Address:
|
|
64
|
+
"""Return the address or raise a sanitized provider error."""
|
|
65
|
+
if not self.service_url:
|
|
66
|
+
raise GeocodingError("service_url_missing")
|
|
67
|
+
if any(
|
|
68
|
+
isinstance(value, bool)
|
|
69
|
+
or not isinstance(value, (float, int))
|
|
70
|
+
or not -limit <= value <= limit
|
|
71
|
+
or not isfinite(value)
|
|
72
|
+
for value, limit in ((latitude, 90), (longitude, 180))
|
|
73
|
+
):
|
|
74
|
+
raise GeocodingError("invalid_coordinates")
|
|
75
|
+
params: dict[str, str | float | int] = {
|
|
76
|
+
"lat": latitude,
|
|
77
|
+
"lon": longitude,
|
|
78
|
+
"format": "json",
|
|
79
|
+
"addressdetails": 1,
|
|
80
|
+
}
|
|
81
|
+
if self.api_key:
|
|
82
|
+
params["key"] = self.api_key
|
|
83
|
+
try:
|
|
84
|
+
async with self.session.get(
|
|
85
|
+
self.service_url,
|
|
86
|
+
params=params,
|
|
87
|
+
headers={"User-Agent": self.user_agent, "Accept": "application/json"},
|
|
88
|
+
timeout=ClientTimeout(total=10),
|
|
89
|
+
allow_redirects=False,
|
|
90
|
+
) as response:
|
|
91
|
+
if response.status != 200:
|
|
92
|
+
raise GeocodingError(f"http_{response.status}")
|
|
93
|
+
try:
|
|
94
|
+
data = await response.json()
|
|
95
|
+
except (ContentTypeError, ValueError):
|
|
96
|
+
raise GeocodingError("invalid_json") from None
|
|
97
|
+
except TimeoutError:
|
|
98
|
+
raise GeocodingError("timeout") from None
|
|
99
|
+
except ClientError:
|
|
100
|
+
raise GeocodingError("connection_error") from None
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
raise GeocodingError("invalid_response")
|
|
103
|
+
display_name = data.get("display_name")
|
|
104
|
+
if not isinstance(display_name, str) or not display_name.strip():
|
|
105
|
+
raise GeocodingError("no_display_name")
|
|
106
|
+
attribution = data.get("attribution") or data.get("licence") or data.get("license")
|
|
107
|
+
return Address(
|
|
108
|
+
display_name.strip(),
|
|
109
|
+
attribution.strip() if isinstance(attribution, str) and attribution.strip() else None,
|
|
110
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nominatim-reverse-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async reverse geocoding with a caller-owned session and explicit provider
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Source, https://github.com/jouwdan/ha-pleos/tree/main/packages/nominatim-reverse-client
|
|
7
|
+
Project-URL: Issues, https://github.com/jouwdan/ha-pleos/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jouwdan/ha-pleos/blob/main/packages/nominatim-reverse-client/CHANGELOG.md
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: aiohttp>=3.9.5
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
19
|
+
Requires-Dist: coverage>=7; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.11; extra == "dev"
|
|
22
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# nominatim-reverse-client
|
|
26
|
+
|
|
27
|
+
An asynchronous Python client for reverse lookup with an explicitly selected Nominatim-compatible provider. It has no Home Assistant dependency and does not select a public provider, cache results, round coordinates, or start background requests.
|
|
28
|
+
|
|
29
|
+
Requires Python 3.11 or later and a caller-owned `aiohttp.ClientSession`.
|
|
30
|
+
|
|
31
|
+
Once the release gate below is complete, install the published distribution with `python -m pip install nominatim-reverse-client`.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import aiohttp
|
|
37
|
+
|
|
38
|
+
from nominatim_reverse_client import GeocodingError, NominatimClient
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def lookup(service_url: str, latitude: float, longitude: float):
|
|
42
|
+
async with aiohttp.ClientSession() as session:
|
|
43
|
+
client = NominatimClient(
|
|
44
|
+
session,
|
|
45
|
+
service_url,
|
|
46
|
+
user_agent="MyApplication (https://example.com/contact)",
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
address = await client.reverse(latitude, longitude)
|
|
50
|
+
except GeocodingError as error:
|
|
51
|
+
# error.code contains no provider URL, API key or coordinates.
|
|
52
|
+
return error.code
|
|
53
|
+
return address
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`reverse()` returns `Address(display_name, attribution)`. The display name is stripped of surrounding whitespace but is otherwise unchanged. Attribution is taken from `attribution`, `licence`, or `license` in the provider's response. Display the attribution and follow the provider's usage and caching terms.
|
|
57
|
+
|
|
58
|
+
Pass a base URL or the provider's `/reverse` endpoint. `reverse_url(value)` returns its normalized endpoint, or an empty string when it is missing or invalid. URLs must use HTTP or HTTPS and cannot contain login credentials, query parameters, or fragments. HTTPS protects coordinates and API keys in transit; HTTP is available for providers operated on a trusted local network.
|
|
59
|
+
|
|
60
|
+
If the selected provider requires a key, pass `api_key="..."`; it is sent using the `key` query parameter. Every lookup also sends `lat`, `lon`, `format=json`, and `addressdetails=1`. Only valid numeric WGS84 coordinates are accepted. Requests use a ten-second timeout and never follow redirects or retry automatically. The client never closes the caller's session.
|
|
61
|
+
|
|
62
|
+
The caller is responsible for obtaining consent before sending coordinates, choosing a provider, respecting its request limits, caching or rounding as appropriate, and protecting addresses in local history. This client logs nothing. Exceptions contain a code only; provider responses and underlying request exceptions are not included in their displayed traceback.
|
|
63
|
+
|
|
64
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
`GeocodingError.code` is one of:
|
|
67
|
+
|
|
68
|
+
- `service_url_missing`: no valid explicit endpoint was supplied.
|
|
69
|
+
- `invalid_coordinates`: coordinates are missing, nonnumeric, nonfinite, or outside WGS84 bounds.
|
|
70
|
+
- `http_<status>`: the provider returned a non-200 status, including redirects and rate limits.
|
|
71
|
+
- `timeout` or `connection_error`: the request could not complete.
|
|
72
|
+
- `invalid_json`, `invalid_response`, or `no_display_name`: the provider response could not supply an address.
|
|
73
|
+
|
|
74
|
+
Cancellation propagates to the caller. Provider errors are not retried; callers decide when another request is appropriate.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
From this package directory:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
python -m pip install -e '.[dev]'
|
|
82
|
+
python -m ruff check .
|
|
83
|
+
python -m ruff format --check .
|
|
84
|
+
python -m mypy
|
|
85
|
+
python -m coverage run -m unittest discover -s tests -v
|
|
86
|
+
python -m coverage report
|
|
87
|
+
python -m build
|
|
88
|
+
python -m twine check dist/*
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Tests use synthetic responses and never contact a provider. The public `publish-geocoder.yml` workflow validates Python 3.11 and 3.14 and builds both a source distribution and wheel.
|
|
92
|
+
|
|
93
|
+
## Release gate
|
|
94
|
+
|
|
95
|
+
The distribution is maintained in this public repository independently of the Home Assistant integration and `pleos-client`. A package release must be published before an integration release pins it.
|
|
96
|
+
|
|
97
|
+
Before the first publication, a maintainer must register or reserve the `nominatim-reverse-client` project on PyPI and configure a trusted publisher for repository `jouwdan/ha-pleos`, workflow `publish-geocoder.yml`, and environment `pypi-nominatim-reverse-client`. Configure that GitHub environment with required maintainer approval. This account setup cannot be provided by repository code.
|
|
98
|
+
|
|
99
|
+
After checks and review, tag the approved commit `nominatim-reverse-client-v<version>` matching this package's `pyproject.toml`, then push that tag. The workflow checks the tag/version match, builds the distributions in public CI, and uses OIDC to publish those same artifacts after environment approval. Ordinary integration tags, branch pushes, pull requests, and manual workflow runs cannot publish this distribution. No PyPI token is stored in the repository.
|
|
100
|
+
|
|
101
|
+
Do not create a GitHub Release for the package tag: GitHub Releases in this repository belong to the Home Assistant integration and are consumed by HACS. Package provenance is the namespaced Git tag and its public build/publish workflow run; package changes are recorded in `CHANGELOG.md`.
|
|
102
|
+
|
|
103
|
+
Verify the published wheel and source distribution can be installed before publishing the dependent integration release. Provider credentials or live addresses are not required for release validation.
|
|
104
|
+
|
|
105
|
+
Licensed under the MIT license.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/nominatim_reverse_client/__init__.py
|
|
5
|
+
src/nominatim_reverse_client/py.typed
|
|
6
|
+
src/nominatim_reverse_client.egg-info/PKG-INFO
|
|
7
|
+
src/nominatim_reverse_client.egg-info/SOURCES.txt
|
|
8
|
+
src/nominatim_reverse_client.egg-info/dependency_links.txt
|
|
9
|
+
src/nominatim_reverse_client.egg-info/requires.txt
|
|
10
|
+
src/nominatim_reverse_client.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nominatim_reverse_client
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Provider boundaries use synthetic responses and never contact a service."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import traceback
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
7
|
+
|
|
8
|
+
from aiohttp import ClientError, ContentTypeError
|
|
9
|
+
|
|
10
|
+
from nominatim_reverse_client import Address, GeocodingError, NominatimClient, reverse_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReverseClientTests(unittest.IsolatedAsyncioTestCase):
|
|
14
|
+
def setUp(self):
|
|
15
|
+
self.session = MagicMock()
|
|
16
|
+
self.client = NominatimClient(
|
|
17
|
+
self.session, "https://geo.example/api", user_agent="SyntheticClient/1.0"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def response(self, payload, status=200):
|
|
21
|
+
response = MagicMock(status=status)
|
|
22
|
+
response.json = AsyncMock(return_value=payload)
|
|
23
|
+
self.session.get.return_value.__aenter__.return_value = response
|
|
24
|
+
return response
|
|
25
|
+
|
|
26
|
+
def test_valid_provider_urls_are_normalized(self):
|
|
27
|
+
for value, expected in (
|
|
28
|
+
(" https://geo.example/// ", "https://geo.example/reverse"),
|
|
29
|
+
("https://geo.example/api", "https://geo.example/api/reverse"),
|
|
30
|
+
("https://geo.example/api/reverse/", "https://geo.example/api/reverse"),
|
|
31
|
+
("http://127.0.0.1:8080", "http://127.0.0.1:8080/reverse"),
|
|
32
|
+
("http://[::1]:8080", "http://[::1]:8080/reverse"),
|
|
33
|
+
):
|
|
34
|
+
with self.subTest(value=value):
|
|
35
|
+
self.assertEqual(reverse_url(value), expected)
|
|
36
|
+
|
|
37
|
+
def test_unsafe_or_missing_urls_are_rejected(self):
|
|
38
|
+
for value in (
|
|
39
|
+
None,
|
|
40
|
+
12,
|
|
41
|
+
"",
|
|
42
|
+
" ",
|
|
43
|
+
"ftp://geo.example",
|
|
44
|
+
"https://",
|
|
45
|
+
"https://[invalid",
|
|
46
|
+
"https://geo.example:invalid",
|
|
47
|
+
"https://geo.example:65536",
|
|
48
|
+
"https://user@geo.example",
|
|
49
|
+
"https://user:secret@geo.example",
|
|
50
|
+
"https://:secret@geo.example",
|
|
51
|
+
"https://geo.example?key=secret",
|
|
52
|
+
"https://geo.example/#secret",
|
|
53
|
+
"https://geo.example?",
|
|
54
|
+
"https://geo.example#",
|
|
55
|
+
):
|
|
56
|
+
with self.subTest(value=value):
|
|
57
|
+
self.assertEqual(reverse_url(value), "")
|
|
58
|
+
|
|
59
|
+
async def test_no_provider_means_no_request(self):
|
|
60
|
+
self.client = NominatimClient(self.session, "", user_agent="SyntheticClient/1.0")
|
|
61
|
+
with self.assertRaises(GeocodingError) as caught:
|
|
62
|
+
await self.client.reverse(55.1, 10.1)
|
|
63
|
+
self.assertEqual(caught.exception.code, "service_url_missing")
|
|
64
|
+
self.session.get.assert_not_called()
|
|
65
|
+
|
|
66
|
+
async def test_lookup_preserves_address_and_caller_session(self):
|
|
67
|
+
address = "Synthetic Street, " * 30
|
|
68
|
+
self.response({"display_name": " " + address + " ", "licence": " Fixture licence "})
|
|
69
|
+
result = await self.client.reverse(55.1234, 10.4321)
|
|
70
|
+
self.assertEqual(result, Address(address.strip(), "Fixture licence"))
|
|
71
|
+
request = self.session.get.call_args
|
|
72
|
+
self.assertEqual(request.args, ("https://geo.example/api/reverse",))
|
|
73
|
+
self.assertEqual(
|
|
74
|
+
request.kwargs["params"],
|
|
75
|
+
{
|
|
76
|
+
"lat": 55.1234,
|
|
77
|
+
"lon": 10.4321,
|
|
78
|
+
"format": "json",
|
|
79
|
+
"addressdetails": 1,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
request.kwargs["headers"],
|
|
84
|
+
{
|
|
85
|
+
"User-Agent": "SyntheticClient/1.0",
|
|
86
|
+
"Accept": "application/json",
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
self.assertEqual(request.kwargs["timeout"].total, 10)
|
|
90
|
+
self.assertIs(request.kwargs["allow_redirects"], False)
|
|
91
|
+
self.session.close.assert_not_called()
|
|
92
|
+
|
|
93
|
+
async def test_explicit_api_key_is_trimmed_and_passed_only_in_query(self):
|
|
94
|
+
self.client = NominatimClient(
|
|
95
|
+
self.session, "https://geo.example", user_agent="Fixture", api_key=" fixture-key "
|
|
96
|
+
)
|
|
97
|
+
self.response({"display_name": "Fixture address"})
|
|
98
|
+
await self.client.reverse(0, 0)
|
|
99
|
+
request = self.session.get.call_args
|
|
100
|
+
self.assertEqual(request.kwargs["params"]["key"], "fixture-key")
|
|
101
|
+
self.assertNotIn("fixture-key", repr(request.args))
|
|
102
|
+
self.assertNotIn("fixture-key", repr(request.kwargs["headers"]))
|
|
103
|
+
|
|
104
|
+
async def test_attribution_keys_and_optional_values(self):
|
|
105
|
+
for attributes, expected in (
|
|
106
|
+
({"attribution": " Provider ", "licence": "Other"}, "Provider"),
|
|
107
|
+
({"licence": " Provider licence "}, "Provider licence"),
|
|
108
|
+
({"license": " Provider license "}, "Provider license"),
|
|
109
|
+
({"attribution": "", "licence": "Fallback"}, "Fallback"),
|
|
110
|
+
({"attribution": " "}, None),
|
|
111
|
+
({"attribution": 123}, None),
|
|
112
|
+
({}, None),
|
|
113
|
+
):
|
|
114
|
+
with self.subTest(attributes=attributes):
|
|
115
|
+
self.response({"display_name": "Fixture", **attributes})
|
|
116
|
+
self.assertEqual((await self.client.reverse(0, 0)).attribution, expected)
|
|
117
|
+
|
|
118
|
+
async def test_coordinate_boundaries_are_valid(self):
|
|
119
|
+
self.response({"display_name": "Fixture"})
|
|
120
|
+
for coordinates in ((-90, -180), (90, 180), (0, 0)):
|
|
121
|
+
with self.subTest(coordinates=coordinates):
|
|
122
|
+
self.assertEqual(await self.client.reverse(*coordinates), Address("Fixture"))
|
|
123
|
+
|
|
124
|
+
async def test_invalid_coordinates_never_leave_the_process(self):
|
|
125
|
+
for coordinates in (
|
|
126
|
+
(None, 0),
|
|
127
|
+
("55.1", 0),
|
|
128
|
+
(False, 0),
|
|
129
|
+
(0, True),
|
|
130
|
+
([], 0),
|
|
131
|
+
(float("nan"), 0),
|
|
132
|
+
(0, float("inf")),
|
|
133
|
+
(float("-inf"), 0),
|
|
134
|
+
(-90.1, 0),
|
|
135
|
+
(90.1, 0),
|
|
136
|
+
(0, -180.1),
|
|
137
|
+
(0, 180.1),
|
|
138
|
+
(10**1000, 0),
|
|
139
|
+
):
|
|
140
|
+
with self.subTest(coordinates=coordinates):
|
|
141
|
+
with self.assertRaises(GeocodingError) as caught:
|
|
142
|
+
await self.client.reverse(*coordinates)
|
|
143
|
+
self.assertEqual(caught.exception.code, "invalid_coordinates")
|
|
144
|
+
self.session.get.assert_not_called()
|
|
145
|
+
|
|
146
|
+
async def test_non_200_responses_never_parse_body_or_retry(self):
|
|
147
|
+
for status in (301, 302, 401, 403, 429, 500):
|
|
148
|
+
with self.subTest(status=status):
|
|
149
|
+
self.session.get.reset_mock()
|
|
150
|
+
response = self.response({"private": "fixture-secret"}, status)
|
|
151
|
+
with self.assertRaises(GeocodingError) as caught:
|
|
152
|
+
await self.client.reverse(55.1, 10.1)
|
|
153
|
+
self.assertEqual(str(caught.exception), f"http_{status}")
|
|
154
|
+
self.session.get.assert_called_once()
|
|
155
|
+
response.json.assert_not_awaited()
|
|
156
|
+
|
|
157
|
+
async def test_request_errors_are_sanitized_and_not_logged(self):
|
|
158
|
+
secret = "https://geo.example?key=fixture-secret&lat=55.123"
|
|
159
|
+
for error, code in (
|
|
160
|
+
(TimeoutError(secret), "timeout"),
|
|
161
|
+
(ClientError(secret), "connection_error"),
|
|
162
|
+
):
|
|
163
|
+
with self.subTest(code=code):
|
|
164
|
+
self.session.get.side_effect = error
|
|
165
|
+
with self.assertNoLogs("nominatim_reverse_client", level="DEBUG"):
|
|
166
|
+
with self.assertRaises(GeocodingError) as caught:
|
|
167
|
+
await self.client.reverse(55.123, 10.1)
|
|
168
|
+
self.assertEqual(caught.exception.code, code)
|
|
169
|
+
rendered = "".join(traceback.format_exception(caught.exception))
|
|
170
|
+
self.assertNotIn(secret, rendered)
|
|
171
|
+
self.assertNotIn("fixture-secret", rendered)
|
|
172
|
+
self.assertTrue(caught.exception.__suppress_context__)
|
|
173
|
+
|
|
174
|
+
async def test_invalid_json_and_content_type_are_sanitized(self):
|
|
175
|
+
for error in (
|
|
176
|
+
ValueError("fixture-private-body"),
|
|
177
|
+
ContentTypeError(request_info=MagicMock(), history=(), message="fixture-private-body"),
|
|
178
|
+
):
|
|
179
|
+
with self.subTest(error=type(error).__name__):
|
|
180
|
+
self.response({}).json.side_effect = error
|
|
181
|
+
with self.assertRaises(GeocodingError) as caught:
|
|
182
|
+
await self.client.reverse(55.1, 10.1)
|
|
183
|
+
self.assertEqual(str(caught.exception), "invalid_json")
|
|
184
|
+
self.assertTrue(caught.exception.__suppress_context__)
|
|
185
|
+
|
|
186
|
+
async def test_missing_or_malformed_address_is_not_returned(self):
|
|
187
|
+
for payload, code in (
|
|
188
|
+
(None, "invalid_response"),
|
|
189
|
+
([], "invalid_response"),
|
|
190
|
+
({}, "no_display_name"),
|
|
191
|
+
({"display_name": 123}, "no_display_name"),
|
|
192
|
+
({"display_name": " "}, "no_display_name"),
|
|
193
|
+
({"error": "fixture-private-error"}, "no_display_name"),
|
|
194
|
+
):
|
|
195
|
+
with self.subTest(payload=payload):
|
|
196
|
+
self.response(payload)
|
|
197
|
+
with self.assertRaises(GeocodingError) as caught:
|
|
198
|
+
await self.client.reverse(55.1, 10.1)
|
|
199
|
+
self.assertEqual(str(caught.exception), code)
|
|
200
|
+
|
|
201
|
+
async def test_cancellation_propagates(self):
|
|
202
|
+
self.session.get.side_effect = asyncio.CancelledError()
|
|
203
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
204
|
+
await self.client.reverse(55.1, 10.1)
|
|
205
|
+
self.session.get.assert_called_once()
|