bboxkit 0.1.0__py3-none-any.whl
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.
- bboxkit/__init__.py +31 -0
- bboxkit/__main__.py +6 -0
- bboxkit/_geo.py +222 -0
- bboxkit/_version.py +1 -0
- bboxkit/bbox.py +1029 -0
- bboxkit/cli.py +311 -0
- bboxkit/crs.py +380 -0
- bboxkit/formats.py +86 -0
- bboxkit/geohash.py +83 -0
- bboxkit/mgrs.py +136 -0
- bboxkit/nominatim.py +113 -0
- bboxkit/parsing.py +722 -0
- bboxkit/pluscode.py +125 -0
- bboxkit/py.typed +0 -0
- bboxkit/tiles.py +148 -0
- bboxkit-0.1.0.dist-info/METADATA +270 -0
- bboxkit-0.1.0.dist-info/RECORD +21 -0
- bboxkit-0.1.0.dist-info/WHEEL +5 -0
- bboxkit-0.1.0.dist-info/entry_points.txt +2 -0
- bboxkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- bboxkit-0.1.0.dist-info/top_level.txt +1 -0
bboxkit/formats.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Format a :class:`~bboxkit.BBox` by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable, Dict
|
|
7
|
+
|
|
8
|
+
from .bbox import BBox
|
|
9
|
+
|
|
10
|
+
__all__ = ["FORMATS", "FORMAT_ALIASES", "format_bbox"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _json(value: Any) -> str:
|
|
14
|
+
return json.dumps(value, separators=(",", ":"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_Formatter = Callable[..., str]
|
|
18
|
+
|
|
19
|
+
FORMATS: Dict[str, str] = {
|
|
20
|
+
"wsen": "west,south,east,north (GeoJSON, Shapely, OSM API, STAC)",
|
|
21
|
+
"swne": "south,west,north,east (lat-first)",
|
|
22
|
+
"bboxfinder": "bboxfinder.com link",
|
|
23
|
+
"overpass": "Overpass QL (s,w,n,e) filter",
|
|
24
|
+
"osm": "openstreetmap.org #map=zoom/lat/lon link that fits the box",
|
|
25
|
+
"wkt": "WKT POLYGON (MULTIPOLYGON when crossing the antimeridian)",
|
|
26
|
+
"envelope": "CQL/Solr ENVELOPE(minX, maxX, maxY, minY)",
|
|
27
|
+
"postgis": "PostGIS ST_MakeEnvelope(xmin, ymin, xmax, ymax, srid)",
|
|
28
|
+
"geojson": "GeoJSON Polygon/MultiPolygon with bbox member",
|
|
29
|
+
"shapely": "Shapely bounds tuple (minx, miny, maxx, maxy)",
|
|
30
|
+
"gdal-te": "gdalwarp -te xmin ymin xmax ymax",
|
|
31
|
+
"gdal-projwin": "gdal_translate -projwin ulx uly lrx lry",
|
|
32
|
+
"leaflet": "Leaflet [[south, west], [north, east]]",
|
|
33
|
+
"mapbox": "Mapbox/MapLibre [[west, south], [east, north]]",
|
|
34
|
+
"tile": "deepest XYZ tile z/x/y containing the box",
|
|
35
|
+
"quadkey": "Bing quadkey of that tile",
|
|
36
|
+
"geohash": "longest geohash containing the box",
|
|
37
|
+
"pluscode": "longest Plus Code containing the box",
|
|
38
|
+
"mgrs": "finest MGRS square containing the box",
|
|
39
|
+
"json": "JSON object {west, south, east, north, crs}",
|
|
40
|
+
}
|
|
41
|
+
"""Format names accepted by :meth:`BBox.format` and ``bboxkit convert --to``."""
|
|
42
|
+
|
|
43
|
+
FORMAT_ALIASES: Dict[str, str] = {
|
|
44
|
+
"bbox": "wsen",
|
|
45
|
+
"csv": "wsen",
|
|
46
|
+
"latlon": "swne",
|
|
47
|
+
"gdal": "gdal-te",
|
|
48
|
+
"te": "gdal-te",
|
|
49
|
+
"projwin": "gdal-projwin",
|
|
50
|
+
"xyz": "tile",
|
|
51
|
+
"plus": "pluscode",
|
|
52
|
+
"olc": "pluscode",
|
|
53
|
+
"bounds": "shapely",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_FORMATTERS: Dict[str, _Formatter] = {
|
|
57
|
+
"wsen": lambda b, **kw: b.to_wsen(**kw),
|
|
58
|
+
"swne": lambda b, **kw: b.to_swne(**kw),
|
|
59
|
+
"bboxfinder": lambda b, **kw: b.to_bboxfinder(**kw),
|
|
60
|
+
"overpass": lambda b, **kw: b.to_overpass(**kw),
|
|
61
|
+
"osm": lambda b, **kw: b.to_osm_url(**kw),
|
|
62
|
+
"wkt": lambda b, **kw: b.to_wkt(**kw),
|
|
63
|
+
"envelope": lambda b, **kw: b.to_wkt_envelope(**kw),
|
|
64
|
+
"postgis": lambda b, **kw: b.to_postgis(**kw),
|
|
65
|
+
"geojson": lambda b, **kw: _json(b.to_geojson(**kw)),
|
|
66
|
+
"shapely": lambda b: _json(list(b.to_shapely_bounds())),
|
|
67
|
+
"gdal-te": lambda b, **kw: b.to_gdal_te(**kw),
|
|
68
|
+
"gdal-projwin": lambda b, **kw: b.to_gdal_projwin(**kw),
|
|
69
|
+
"leaflet": lambda b: _json(b.to_leaflet()),
|
|
70
|
+
"mapbox": lambda b: _json(b.to_mapbox()),
|
|
71
|
+
"tile": lambda b, **kw: str(b.to_tile(**kw)),
|
|
72
|
+
"quadkey": lambda b, **kw: b.to_quadkey(**kw),
|
|
73
|
+
"geohash": lambda b, **kw: b.to_geohash(**kw),
|
|
74
|
+
"pluscode": lambda b: b.to_pluscode(),
|
|
75
|
+
"mgrs": lambda b: b.to_mgrs(),
|
|
76
|
+
"json": lambda b: _json(b.to_dict()),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_bbox(bbox: BBox, fmt: str, **kwargs: Any) -> str:
|
|
81
|
+
"""Render ``bbox`` in the format called ``fmt`` (see :data:`FORMATS`)."""
|
|
82
|
+
key = fmt.lower().replace("_", "-")
|
|
83
|
+
key = FORMAT_ALIASES.get(key, key)
|
|
84
|
+
if key not in _FORMATTERS:
|
|
85
|
+
raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(FORMATS)}")
|
|
86
|
+
return _FORMATTERS[key](bbox, **kwargs)
|
bboxkit/geohash.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Geohash cells (Gustavo Niemeyer's base-32 interleaved lon/lat code)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
|
|
7
|
+
__all__ = ["BASE32", "decode_bounds", "encode", "MAX_PRECISION"]
|
|
8
|
+
|
|
9
|
+
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
|
|
10
|
+
_INDEX = {c: i for i, c in enumerate(BASE32)}
|
|
11
|
+
|
|
12
|
+
#: 12 characters is about 3.7 cm x 1.9 cm, well past float-precision GPS.
|
|
13
|
+
MAX_PRECISION = 12
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def decode_bounds(geohash: str) -> Tuple[float, float, float, float]:
|
|
17
|
+
"""``(west, south, east, north)`` of a geohash cell."""
|
|
18
|
+
gh = geohash.strip().lower()
|
|
19
|
+
if not gh:
|
|
20
|
+
raise ValueError("empty geohash")
|
|
21
|
+
if len(gh) > 22:
|
|
22
|
+
raise ValueError("geohash is too long")
|
|
23
|
+
lon_lo, lon_hi = -180.0, 180.0
|
|
24
|
+
lat_lo, lat_hi = -90.0, 90.0
|
|
25
|
+
even = True
|
|
26
|
+
for ch in gh:
|
|
27
|
+
if ch not in _INDEX:
|
|
28
|
+
raise ValueError(f"invalid geohash character {ch!r} in {geohash!r}")
|
|
29
|
+
bits = _INDEX[ch]
|
|
30
|
+
for shift in range(4, -1, -1):
|
|
31
|
+
bit = (bits >> shift) & 1
|
|
32
|
+
if even:
|
|
33
|
+
mid = (lon_lo + lon_hi) / 2.0
|
|
34
|
+
if bit:
|
|
35
|
+
lon_lo = mid
|
|
36
|
+
else:
|
|
37
|
+
lon_hi = mid
|
|
38
|
+
else:
|
|
39
|
+
mid = (lat_lo + lat_hi) / 2.0
|
|
40
|
+
if bit:
|
|
41
|
+
lat_lo = mid
|
|
42
|
+
else:
|
|
43
|
+
lat_hi = mid
|
|
44
|
+
even = not even
|
|
45
|
+
return (lon_lo, lat_lo, lon_hi, lat_hi)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def encode(lon: float, lat: float, precision: int = 9) -> str:
|
|
49
|
+
"""Geohash of a point with ``precision`` characters."""
|
|
50
|
+
if not 1 <= precision <= 22:
|
|
51
|
+
raise ValueError("precision must be 1..22")
|
|
52
|
+
if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
|
|
53
|
+
raise ValueError(f"point ({lon}, {lat}) is outside lon/lat range")
|
|
54
|
+
lon_lo, lon_hi = -180.0, 180.0
|
|
55
|
+
lat_lo, lat_hi = -90.0, 90.0
|
|
56
|
+
out: List[str] = []
|
|
57
|
+
even = True
|
|
58
|
+
bits = 0
|
|
59
|
+
nbits = 0
|
|
60
|
+
while len(out) < precision:
|
|
61
|
+
if even:
|
|
62
|
+
mid = (lon_lo + lon_hi) / 2.0
|
|
63
|
+
if lon >= mid:
|
|
64
|
+
bits = (bits << 1) | 1
|
|
65
|
+
lon_lo = mid
|
|
66
|
+
else:
|
|
67
|
+
bits <<= 1
|
|
68
|
+
lon_hi = mid
|
|
69
|
+
else:
|
|
70
|
+
mid = (lat_lo + lat_hi) / 2.0
|
|
71
|
+
if lat >= mid:
|
|
72
|
+
bits = (bits << 1) | 1
|
|
73
|
+
lat_lo = mid
|
|
74
|
+
else:
|
|
75
|
+
bits <<= 1
|
|
76
|
+
lat_hi = mid
|
|
77
|
+
even = not even
|
|
78
|
+
nbits += 1
|
|
79
|
+
if nbits == 5:
|
|
80
|
+
out.append(BASE32[bits])
|
|
81
|
+
bits = 0
|
|
82
|
+
nbits = 0
|
|
83
|
+
return "".join(out)
|
bboxkit/mgrs.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""MGRS grid squares (UTM part, latitudes 80 S to 84 N) on WGS 84.
|
|
2
|
+
|
|
3
|
+
Polar UPS squares (bands A, B, Y, Z) are not supported.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from typing import Tuple
|
|
11
|
+
|
|
12
|
+
from ._geo import wrap_lon
|
|
13
|
+
from .crs import lonlat_to_utm, utm_to_lonlat, utm_zone
|
|
14
|
+
|
|
15
|
+
__all__ = ["parse", "square_bounds", "encode", "band_letter"]
|
|
16
|
+
|
|
17
|
+
_BANDS = "CDEFGHJKLMNPQRSTUVWX"
|
|
18
|
+
_COL_SETS = ("ABCDEFGH", "JKLMNPQR", "STUVWXYZ")
|
|
19
|
+
_ROWS = "ABCDEFGHJKLMNPQRSTUV"
|
|
20
|
+
_PATTERN = re.compile(
|
|
21
|
+
r"^\s*(\d{1,2})\s*([C-HJ-NP-X])\s*([A-HJ-NP-Z])([A-HJ-NP-V])\s*(\d*)\s*(\d*)\s*$"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def band_letter(lat: float) -> str:
|
|
26
|
+
"""MGRS latitude band letter (C..X) for -80 <= lat <= 84."""
|
|
27
|
+
if not -80.0 <= lat <= 84.0:
|
|
28
|
+
raise ValueError("MGRS/UTM covers latitudes 80 S to 84 N only")
|
|
29
|
+
return _BANDS[min(int((lat + 80.0) // 8.0), len(_BANDS) - 1)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _band_lat_range(band: str) -> Tuple[float, float]:
|
|
33
|
+
i = _BANDS.index(band)
|
|
34
|
+
south = -80.0 + 8.0 * i
|
|
35
|
+
return (south, 84.0 if band == "X" else south + 8.0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _zone_lon_range(zone: int, band: str) -> Tuple[float, float]:
|
|
39
|
+
west = zone * 6.0 - 186.0
|
|
40
|
+
east = west + 6.0
|
|
41
|
+
if band == "V" and zone == 31:
|
|
42
|
+
east = 3.0
|
|
43
|
+
elif band == "V" and zone == 32:
|
|
44
|
+
west = 3.0
|
|
45
|
+
elif band == "X" and zone in (31, 33, 35, 37):
|
|
46
|
+
west, east = {31: (0.0, 9.0), 33: (9.0, 21.0), 35: (21.0, 33.0), 37: (33.0, 42.0)}[zone]
|
|
47
|
+
elif band == "X" and zone in (32, 34, 36):
|
|
48
|
+
raise ValueError(f"MGRS zone {zone}X does not exist")
|
|
49
|
+
return (west, east)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse(text: str) -> Tuple[int, str, float, float, float]:
|
|
53
|
+
"""Split an MGRS reference into ``(zone, band, easting, northing, size_m)``."""
|
|
54
|
+
m = _PATTERN.match(text.upper())
|
|
55
|
+
if not m:
|
|
56
|
+
raise ValueError(f"{text!r} is not an MGRS reference")
|
|
57
|
+
zone = int(m.group(1))
|
|
58
|
+
band, col, row = m.group(2), m.group(3), m.group(4)
|
|
59
|
+
digits = m.group(5) + m.group(6)
|
|
60
|
+
if not 1 <= zone <= 60:
|
|
61
|
+
raise ValueError(f"MGRS zone must be 1..60, got {zone}")
|
|
62
|
+
if len(digits) % 2 or len(digits) > 10:
|
|
63
|
+
raise ValueError(f"{text!r}: easting/northing digits must be an even count up to 10")
|
|
64
|
+
col_set = _COL_SETS[(zone - 1) % 3]
|
|
65
|
+
if col not in col_set:
|
|
66
|
+
raise ValueError(f"column letter {col!r} is not used in zone {zone}")
|
|
67
|
+
precision = len(digits) // 2
|
|
68
|
+
size = 10.0 ** (5 - precision)
|
|
69
|
+
e_off = float(digits[:precision]) * size if precision else 0.0
|
|
70
|
+
n_off = float(digits[precision:]) * size if precision else 0.0
|
|
71
|
+
easting = (col_set.index(col) + 1) * 100000.0 + e_off
|
|
72
|
+
row_offset = 5 if zone % 2 == 0 else 0
|
|
73
|
+
row_n = ((_ROWS.index(row) - row_offset) % 20) * 100000.0 + n_off
|
|
74
|
+
# Resolve the 2,000 km northing ambiguity with the latitude band.
|
|
75
|
+
band_south, _ = _band_lat_range(band)
|
|
76
|
+
south = band_south < 0
|
|
77
|
+
lon_w, lon_e = _zone_lon_range(zone, band)
|
|
78
|
+
band_n = min(lonlat_to_utm(lon, band_south, zone, south)[1] for lon in (lon_w, lon_e))
|
|
79
|
+
band_n = min(band_n, lonlat_to_utm(zone * 6.0 - 183.0, band_south, zone, south)[1])
|
|
80
|
+
band_n = math.floor(band_n / 100000.0) * 100000.0
|
|
81
|
+
northing = row_n
|
|
82
|
+
while northing + size <= band_n:
|
|
83
|
+
northing += 2000000.0
|
|
84
|
+
return (zone, band, easting, northing, size)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def square_bounds(text: str, densify: int = 16) -> Tuple[float, float, float, float]:
|
|
88
|
+
"""``(west, south, east, north)`` of an MGRS square, clipped to its zone and band."""
|
|
89
|
+
zone, band, easting, northing, size = parse(text)
|
|
90
|
+
south = _band_lat_range(band)[0] < 0
|
|
91
|
+
lons = []
|
|
92
|
+
lats = []
|
|
93
|
+
steps = max(1, densify)
|
|
94
|
+
for i in range(steps + 1):
|
|
95
|
+
t = i / steps
|
|
96
|
+
for e, n in (
|
|
97
|
+
(easting + t * size, northing),
|
|
98
|
+
(easting + t * size, northing + size),
|
|
99
|
+
(easting, northing + t * size),
|
|
100
|
+
(easting + size, northing + t * size),
|
|
101
|
+
):
|
|
102
|
+
lon, lat = utm_to_lonlat(e, n, zone, south)
|
|
103
|
+
lons.append(lon)
|
|
104
|
+
lats.append(lat)
|
|
105
|
+
zw, ze = _zone_lon_range(zone, band)
|
|
106
|
+
bs, bn = _band_lat_range(band)
|
|
107
|
+
west, east = max(min(lons), zw), min(max(lons), ze)
|
|
108
|
+
s, n = max(min(lats), bs), min(max(lats), bn)
|
|
109
|
+
if west >= east or s >= n:
|
|
110
|
+
raise ValueError(f"MGRS square {text!r} lies outside its zone and band")
|
|
111
|
+
w = wrap_lon(west) if west < -180.0 else west
|
|
112
|
+
e = wrap_lon(east) if east > 180.0 else east
|
|
113
|
+
return (w, s, e, n)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def encode(lon: float, lat: float, precision: int = 5) -> str:
|
|
117
|
+
"""MGRS reference of a point; ``precision`` digits per axis (0 = 100 km)."""
|
|
118
|
+
if not 0 <= precision <= 5:
|
|
119
|
+
raise ValueError("MGRS precision must be 0..5")
|
|
120
|
+
band = band_letter(lat)
|
|
121
|
+
zone = utm_zone(lon, lat)
|
|
122
|
+
south = lat < 0
|
|
123
|
+
easting, northing = lonlat_to_utm(lon, lat, zone, south)
|
|
124
|
+
col_set = _COL_SETS[(zone - 1) % 3]
|
|
125
|
+
col_i = int(easting // 100000.0) - 1
|
|
126
|
+
if not 0 <= col_i < len(col_set):
|
|
127
|
+
raise ValueError("point is outside the UTM zone's easting range")
|
|
128
|
+
row_offset = 5 if zone % 2 == 0 else 0
|
|
129
|
+
row_i = (int(northing // 100000.0) + row_offset) % 20
|
|
130
|
+
ref = f"{zone:02d}{band}{col_set[col_i]}{_ROWS[row_i]}"
|
|
131
|
+
if precision:
|
|
132
|
+
size = 10.0 ** (5 - precision)
|
|
133
|
+
e = int((easting % 100000.0) // size)
|
|
134
|
+
n = int((northing % 100000.0) // size)
|
|
135
|
+
ref += f"{e:0{precision}d}{n:0{precision}d}"
|
|
136
|
+
return ref
|
bboxkit/nominatim.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Optional place-name lookup through a Nominatim server.
|
|
2
|
+
|
|
3
|
+
Nothing in bboxkit talks to the network unless you call this module
|
|
4
|
+
explicitly. The public server at nominatim.openstreetmap.org has a usage
|
|
5
|
+
policy (https://operations.osmfoundation.org/policies/nominatim/) that this
|
|
6
|
+
client follows:
|
|
7
|
+
|
|
8
|
+
* at most one request per second (enforced per process),
|
|
9
|
+
* an identifying ``User-Agent`` (set your own, ideally with a contact),
|
|
10
|
+
* results are cached in memory so repeated queries are not re-sent,
|
|
11
|
+
* no bulk geocoding: use your own Nominatim instance for that (``base_url``).
|
|
12
|
+
|
|
13
|
+
Results are (c) OpenStreetMap contributors, ODbL 1.0.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
23
|
+
from urllib.parse import urlencode
|
|
24
|
+
from urllib.request import Request, urlopen
|
|
25
|
+
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .bbox import BBox
|
|
28
|
+
|
|
29
|
+
__all__ = ["NOMINATIM_URL", "DEFAULT_USER_AGENT", "search", "geocode_bbox", "clear_cache"]
|
|
30
|
+
|
|
31
|
+
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
|
32
|
+
DEFAULT_USER_AGENT = f"bboxkit/{__version__} (+https://github.com/YusufEminoglu/bboxkit_sdk)"
|
|
33
|
+
MIN_INTERVAL_S = 1.0
|
|
34
|
+
_CACHE_SIZE = 256
|
|
35
|
+
|
|
36
|
+
_lock = threading.Lock()
|
|
37
|
+
_last_request = 0.0
|
|
38
|
+
_cache: OrderedDict[Tuple[Any, ...], List[Dict[str, Any]]] = OrderedDict()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clear_cache() -> None:
|
|
42
|
+
"""Forget cached lookups."""
|
|
43
|
+
with _lock:
|
|
44
|
+
_cache.clear()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _throttle() -> None:
|
|
48
|
+
global _last_request
|
|
49
|
+
wait = _last_request + MIN_INTERVAL_S - time.monotonic()
|
|
50
|
+
if wait > 0:
|
|
51
|
+
time.sleep(wait)
|
|
52
|
+
_last_request = time.monotonic()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def search(
|
|
56
|
+
query: str,
|
|
57
|
+
*,
|
|
58
|
+
limit: int = 1,
|
|
59
|
+
user_agent: Optional[str] = None,
|
|
60
|
+
email: Optional[str] = None,
|
|
61
|
+
timeout: float = 10.0,
|
|
62
|
+
base_url: str = NOMINATIM_URL,
|
|
63
|
+
accept_language: Optional[str] = None,
|
|
64
|
+
country_codes: Optional[str] = None,
|
|
65
|
+
) -> List[Dict[str, Any]]:
|
|
66
|
+
"""Run a Nominatim free-text search and return the decoded JSON results."""
|
|
67
|
+
q = query.strip()
|
|
68
|
+
if not q:
|
|
69
|
+
raise ValueError("empty query")
|
|
70
|
+
if not 1 <= limit <= 40:
|
|
71
|
+
raise ValueError("limit must be 1..40")
|
|
72
|
+
if timeout <= 0:
|
|
73
|
+
raise ValueError("timeout must be positive")
|
|
74
|
+
params: Dict[str, str] = {"q": q, "format": "jsonv2", "limit": str(limit)}
|
|
75
|
+
if email:
|
|
76
|
+
params["email"] = email
|
|
77
|
+
if accept_language:
|
|
78
|
+
params["accept-language"] = accept_language
|
|
79
|
+
if country_codes:
|
|
80
|
+
params["countrycodes"] = country_codes
|
|
81
|
+
key = (base_url, tuple(sorted(params.items())))
|
|
82
|
+
with _lock:
|
|
83
|
+
if key in _cache:
|
|
84
|
+
_cache.move_to_end(key)
|
|
85
|
+
return list(_cache[key])
|
|
86
|
+
_throttle()
|
|
87
|
+
url = f"{base_url}?{urlencode(params)}"
|
|
88
|
+
req = Request(
|
|
89
|
+
url,
|
|
90
|
+
headers={"User-Agent": user_agent or DEFAULT_USER_AGENT, "Accept": "application/json"},
|
|
91
|
+
)
|
|
92
|
+
with urlopen(req, timeout=timeout) as resp: # noqa: S310 - https URL built above
|
|
93
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
94
|
+
if not isinstance(data, list):
|
|
95
|
+
raise ValueError("unexpected Nominatim response")
|
|
96
|
+
_cache[key] = data
|
|
97
|
+
while len(_cache) > _CACHE_SIZE:
|
|
98
|
+
_cache.popitem(last=False)
|
|
99
|
+
return list(data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def geocode_bbox(query: str, **kwargs: Any) -> BBox:
|
|
103
|
+
"""Bounding box of the best Nominatim match for a place name.
|
|
104
|
+
|
|
105
|
+
Keyword arguments are passed to :func:`search`. Raises ``LookupError``
|
|
106
|
+
when nothing matches.
|
|
107
|
+
"""
|
|
108
|
+
kwargs.setdefault("limit", 1)
|
|
109
|
+
results = search(query, **kwargs)
|
|
110
|
+
if not results:
|
|
111
|
+
raise LookupError(f"no Nominatim result for {query!r}")
|
|
112
|
+
s, n, w, e = (float(v) for v in results[0]["boundingbox"])
|
|
113
|
+
return BBox(w, s, e, n)
|