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/parsing.py ADDED
@@ -0,0 +1,722 @@
1
+ """Parse bounding boxes from strings, URLs, GeoJSON, WKT, tiles and grid codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import re
9
+ import sys
10
+ import warnings
11
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
12
+ from urllib.parse import parse_qs, urlsplit
13
+
14
+ from . import geohash as _geohash
15
+ from . import mgrs as _mgrs
16
+ from . import pluscode as _pluscode
17
+ from . import tiles as _tiles
18
+ from ._geo import MERCATOR_MAX_LAT, Arc, component_arc, covering_arc, lon_arc, snap_arc_edges
19
+ from .bbox import BBox
20
+ from .crs import WGS84, CRSLike, is_geographic, normalize_crs
21
+
22
+ __all__ = [
23
+ "AmbiguousOrderWarning",
24
+ "parse",
25
+ "parse_numbers",
26
+ "parse_bboxfinder",
27
+ "parse_overpass",
28
+ "parse_osm_url",
29
+ "parse_wkt",
30
+ "parse_geojson",
31
+ "parse_gdal",
32
+ "parse_leaflet",
33
+ "parse_mapbox",
34
+ "parse_tile",
35
+ "parse_quadkey",
36
+ "parse_geohash",
37
+ "parse_pluscode",
38
+ "parse_mgrs",
39
+ "parse_postgis",
40
+ "PARSERS",
41
+ ]
42
+
43
+ Viewport = Tuple[int, int]
44
+
45
+
46
+ _PKG_DIR = os.path.dirname(os.path.abspath(__file__))
47
+
48
+
49
+ def _warn(message: str) -> None:
50
+ """Warn with the location of the first caller outside bboxkit."""
51
+ level = 1
52
+ frame = sys._getframe(0)
53
+ while frame is not None and os.path.abspath(frame.f_code.co_filename).startswith(_PKG_DIR):
54
+ frame = frame.f_back # type: ignore[assignment]
55
+ level += 1
56
+ warnings.warn(message, AmbiguousOrderWarning, stacklevel=level)
57
+
58
+
59
+ class AmbiguousOrderWarning(UserWarning):
60
+ """Four numbers could be read both as W,S,E,N and as S,W,N,E.
61
+
62
+ Pass ``order="wsen"`` or ``order="swne"`` to state the order explicitly
63
+ (and silence the warning).
64
+ """
65
+
66
+
67
+ _NUM = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
68
+ _NUM_RE = re.compile(_NUM)
69
+ _NUMS_ONLY = re.compile(rf"^\s*{_NUM}(?:\s*[,;\s]\s*{_NUM})*\s*$")
70
+ _CRS_TAIL = re.compile(
71
+ r"^(?P<body>.*?)[\s,;]+(?P<crs>(?:EPSG|ESRI|OGC|CRS84|urn:ogc|https?://www\.opengis)\S*)\s*$",
72
+ re.IGNORECASE,
73
+ )
74
+ _WKT_RE = re.compile(
75
+ r"^\s*(?:SRID=(?P<srid>\d+)\s*;)?\s*(?P<kind>POINT|LINESTRING|POLYGON|MULTIPOINT|"
76
+ r"MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION|ENVELOPE|BOX3D|BOX2D|BOX)\b",
77
+ re.IGNORECASE,
78
+ )
79
+ _OVERPASS_RE = re.compile(
80
+ rf"^\s*(?:\[\s*bbox\s*:\s*(?P<a>{_NUM}(?:\s*,\s*{_NUM}){{3}})\s*\]\s*;?"
81
+ rf"|\(\s*(?P<b>{_NUM}(?:\s*,\s*{_NUM}){{3}})\s*\))\s*$",
82
+ re.IGNORECASE,
83
+ )
84
+ _TILE_RE = re.compile(r"^\s*(?:tile:)?(\d{1,2})/(\d+)/(\d+)(?:@\dx)?(?:\.\w+)?\s*$", re.IGNORECASE)
85
+ _TILE_URL_RE = re.compile(r"/(\d{1,2})/(\d+)/(\d+)(?:@\dx)?(?:\.\w+)?(?:\?.*)?$")
86
+ _PLUS_RE = re.compile(r"^\s*[23456789CFGHJMPQRVWX0]{2,8}\+[23456789CFGHJMPQRVWX]*\s*$", re.I)
87
+ _QUADKEY_RE = re.compile(r"^[0-3]{1,30}$")
88
+ _GEOHASH_RE = re.compile(r"^[0-9b-hjkmnp-z]{1,22}$")
89
+ _GDAL_RE = re.compile(r"(?:^|\s)-(?:te|projwin|spat)\s")
90
+ _POSTGIS_RE = re.compile(r"^\s*(?:ST_)?MakeEnvelope\s*\((?P<args>[^)]*)\)\s*;?\s*$", re.I)
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # Plain numbers and the W,S,E,N / S,W,N,E question
95
+ # --------------------------------------------------------------------------
96
+
97
+
98
+ def _plausible(w: float, s: float, e: float, n: float, unwrapped: bool) -> bool:
99
+ lon_ok = -180.0 <= w <= 180.0 and (-180.0 <= e <= 180.0 or (unwrapped and w <= e <= w + 360.0))
100
+ return lon_ok and -90.0 <= s <= n <= 90.0
101
+
102
+
103
+ def _build(w: float, s: float, e: float, n: float, crs: str, unwrapped: bool = False) -> BBox:
104
+ if unwrapped and e > 180.0 and w <= e:
105
+ return BBox.wrap(w, s, e, n, crs=crs)
106
+ return BBox(w, s, e, n, crs=crs)
107
+
108
+
109
+ def _from_four(
110
+ vals: Sequence[float],
111
+ order: Optional[str],
112
+ crs: str,
113
+ default: str = "wsen",
114
+ unwrapped: bool = False,
115
+ ) -> BBox:
116
+ if len(vals) != 4:
117
+ raise ValueError(f"expected 4 numbers, got {len(vals)}")
118
+ a, b, c, d = (float(v) for v in vals)
119
+ readings = {"wsen": (a, b, c, d), "swne": (b, a, d, c)}
120
+ if order is not None:
121
+ key = order.lower().replace(",", "").replace(" ", "")
122
+ if key not in readings:
123
+ raise ValueError("order must be 'wsen' or 'swne'")
124
+ return _build(*readings[key], crs=crs, unwrapped=unwrapped)
125
+ if not is_geographic(crs):
126
+ return _build(*readings["wsen"], crs=crs)
127
+
128
+ def score(key: str) -> Tuple[bool, bool]:
129
+ w, s, e, n = readings[key]
130
+ ok = _plausible(w, s, e, n, unwrapped)
131
+ return (ok, ok and w <= e)
132
+
133
+ other = "swne" if default == "wsen" else "wsen"
134
+ s_def, s_oth = score(default), score(other)
135
+ chosen = other if s_oth > s_def else default
136
+ loser = other if chosen == default else default
137
+ ambiguous = score(loser)[0] and readings["wsen"] != readings["swne"]
138
+ if ambiguous:
139
+ names = {"wsen": "west,south,east,north", "swne": "south,west,north,east"}
140
+ _warn(
141
+ f"{a:g},{b:g},{c:g},{d:g} is ambiguous; read as {names[chosen]}. "
142
+ f"Pass order='{chosen}' or order='{loser}' to be explicit."
143
+ )
144
+ return _build(*readings[chosen], crs=crs, unwrapped=unwrapped)
145
+
146
+
147
+ def _numbers(text: str) -> List[float]:
148
+ return [float(x) for x in _NUM_RE.findall(text)]
149
+
150
+
151
+ def parse_numbers(text: Any, order: Optional[str] = None, crs: CRSLike = WGS84) -> BBox:
152
+ """Four numbers as text (``"29,40.9,29.2,41.1"``) or a sequence.
153
+
154
+ Without ``order`` the reading that gives a valid, non-crossing box wins;
155
+ W,S,E,N is assumed when both orders are valid, with an
156
+ :class:`AmbiguousOrderWarning`.
157
+ """
158
+ c = normalize_crs(crs)
159
+ if isinstance(text, str):
160
+ m = _CRS_TAIL.match(text)
161
+ if m:
162
+ text, c = m.group("body"), normalize_crs(m.group("crs"))
163
+ vals = _numbers(text)
164
+ else:
165
+ vals = [float(v) for v in text]
166
+ return _from_four(vals, order, c)
167
+
168
+
169
+ # --------------------------------------------------------------------------
170
+ # Web formats
171
+ # --------------------------------------------------------------------------
172
+
173
+
174
+ def parse_bboxfinder(text: str) -> BBox:
175
+ """bboxfinder.com URL; its fragment is ``#south,west,north,east``."""
176
+ frag = urlsplit(text.strip()).fragment or text
177
+ return _from_four(_numbers(frag), "swne", WGS84)
178
+
179
+
180
+ def parse_overpass(text: str) -> BBox:
181
+ """Overpass QL ``(s,w,n,e)`` filter or ``[bbox:s,w,n,e]`` setting."""
182
+ m = _OVERPASS_RE.match(text)
183
+ if not m:
184
+ raise ValueError(f"{text!r} is not an Overpass bbox")
185
+ return _from_four(_numbers(m.group("a") or m.group("b")), "swne", WGS84)
186
+
187
+
188
+ def _map_view_bbox(zoom: float, lat: float, lon: float, viewport: Viewport) -> BBox:
189
+ vw, vh = viewport
190
+ if vw <= 0 or vh <= 0:
191
+ raise ValueError("viewport must be positive")
192
+ scale = 256.0 * 2.0**zoom
193
+ dx = vw / 2.0 / scale * 360.0
194
+ lat_c = max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, lat))
195
+ yc = (1.0 - math.asinh(math.tan(math.radians(lat_c))) / math.pi) / 2.0
196
+ half = vh / 2.0 / scale
197
+ y_top, y_bot = max(0.0, yc - half), min(1.0, yc + half)
198
+
199
+ def lat_of(y: float) -> float:
200
+ return math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * y))))
201
+
202
+ return BBox.wrap(lon - dx, lat_of(y_bot), lon + dx, lat_of(y_top))
203
+
204
+
205
+ def parse_osm_url(text: str, viewport: Viewport = (1024, 768)) -> BBox:
206
+ """OpenStreetMap-style URL.
207
+
208
+ Understands ``bbox=W,S,E,N`` (OSM API, many WFS/REST services),
209
+ ``minlon=&minlat=&maxlon=&maxlat=`` and ``map=zoom/lat/lon`` in the query
210
+ or fragment; a ``map=`` view is turned into the area a ``viewport``
211
+ (pixels, default 1024 x 768) shows at that zoom.
212
+ """
213
+ parts = urlsplit(text.strip())
214
+ params: Dict[str, List[str]] = {}
215
+ for chunk in (parts.query, parts.fragment):
216
+ for k, v in parse_qs(chunk).items():
217
+ params.setdefault(k.lower(), v)
218
+ if "bbox" in params:
219
+ return _from_four(_numbers(params["bbox"][0]), "wsen", WGS84)
220
+ keys = ("minlon", "minlat", "maxlon", "maxlat")
221
+ if all(k in params for k in keys):
222
+ w, s, e, n = (float(params[k][0]) for k in keys)
223
+ return BBox(w, s, e, n)
224
+ if "map" in params:
225
+ nums = _numbers(params["map"][0])
226
+ if len(nums) >= 3:
227
+ return _map_view_bbox(nums[0], nums[1], nums[2], viewport)
228
+ raise ValueError(f"no bbox=, minlon/minlat/maxlon/maxlat or map= found in {text!r}")
229
+
230
+
231
+ def _pairs(value: Any) -> List[float]:
232
+ if isinstance(value, str):
233
+ value = json.loads(value)
234
+ if (
235
+ isinstance(value, (list, tuple))
236
+ and len(value) == 2
237
+ and all(isinstance(p, (list, tuple)) and len(p) == 2 for p in value)
238
+ ):
239
+ return [float(value[0][0]), float(value[0][1]), float(value[1][0]), float(value[1][1])]
240
+ raise ValueError("expected a pair of pairs like [[a, b], [c, d]]")
241
+
242
+
243
+ def parse_leaflet(value: Any) -> BBox:
244
+ """Leaflet / Google ``[[south, west], [north, east]]`` (east may exceed 180)."""
245
+ return _from_four(_pairs(value), "swne", WGS84, unwrapped=True)
246
+
247
+
248
+ def parse_mapbox(value: Any) -> BBox:
249
+ """Mapbox / MapLibre ``[[west, south], [east, north]]`` (east may exceed 180)."""
250
+ return _from_four(_pairs(value), "wsen", WGS84, unwrapped=True)
251
+
252
+
253
+ def parse_gdal(text: str, crs: Optional[CRSLike] = None) -> BBox:
254
+ """GDAL/OGR arguments: ``-te xmin ymin xmax ymax``, ``-projwin ulx uly lrx lry``
255
+ or ``-spat xmin ymin xmax ymax``, optionally with ``-te_srs`` / ``-projwin_srs``
256
+ / ``-spat_srs`` (``-t_srs`` is used for ``-te`` when no ``-te_srs`` is given).
257
+ A whole command line works too.
258
+ """
259
+ tokens = text.split()
260
+ found: Optional[Tuple[str, List[float]]] = None
261
+ srs: Dict[str, str] = {}
262
+ for i, tok in enumerate(tokens):
263
+ key = tok.lower()
264
+ if key in ("-te", "-projwin", "-spat") and found is None:
265
+ nums = tokens[i + 1 : i + 5]
266
+ if len(nums) < 4:
267
+ raise ValueError(f"{tok} needs four numbers")
268
+ found = (key, [float(x) for x in nums])
269
+ elif key in ("-te_srs", "-projwin_srs", "-spat_srs", "-t_srs") and i + 1 < len(tokens):
270
+ srs[key] = tokens[i + 1].strip("'\"")
271
+ if found is None:
272
+ raise ValueError("no -te, -projwin or -spat option found")
273
+ key, v = found
274
+ src = srs.get(key + "_srs") or (srs.get("-t_srs") if key == "-te" else None)
275
+ c = normalize_crs(src) if src else normalize_crs(crs if crs is not None else WGS84)
276
+ if key == "-projwin":
277
+ return BBox(v[0], v[3], v[2], v[1], crs=c)
278
+ return BBox(v[0], v[1], v[2], v[3], crs=c)
279
+
280
+
281
+ def parse_postgis(text: str, crs: Optional[CRSLike] = None) -> BBox:
282
+ """PostGIS ``ST_MakeEnvelope(xmin, ymin, xmax, ymax[, srid])``."""
283
+ m = _POSTGIS_RE.match(text)
284
+ if not m:
285
+ raise ValueError(f"{text!r} is not an ST_MakeEnvelope call")
286
+ nums = _numbers(m.group("args"))
287
+ if len(nums) not in (4, 5):
288
+ raise ValueError("ST_MakeEnvelope needs 4 numbers and an optional SRID")
289
+ c: CRSLike = crs if crs is not None else WGS84
290
+ if len(nums) == 5 and int(nums[4]) > 0:
291
+ c = int(nums[4])
292
+ return BBox(nums[0], nums[1], nums[2], nums[3], crs=c)
293
+
294
+
295
+ # --------------------------------------------------------------------------
296
+ # Geometry: GeoJSON and WKT
297
+ # --------------------------------------------------------------------------
298
+
299
+
300
+ class _Extent:
301
+ """Accumulates longitude arcs and latitudes of geometry components."""
302
+
303
+ def __init__(self, geographic: bool) -> None:
304
+ self.geographic = geographic
305
+ self.arcs: List[Arc] = []
306
+ self.xs: List[float] = []
307
+ self.ys: List[float] = []
308
+
309
+ def add_component(self, coords: Sequence[Sequence[float]]) -> None:
310
+ if any(len(p) < 2 for p in coords):
311
+ raise ValueError("every position needs at least two coordinates")
312
+ pts = [(float(p[0]), float(p[1])) for p in coords]
313
+ if not pts:
314
+ return
315
+ xs = [p[0] for p in pts]
316
+ self.ys.extend(p[1] for p in pts)
317
+ self.xs.extend(xs)
318
+ if self.geographic:
319
+ self.arcs.append(component_arc(xs))
320
+
321
+ def add_bbox(self, w: float, s: float, e: float, n: float) -> None:
322
+ self.ys.extend((s, n))
323
+ self.xs.extend((w, e))
324
+ if self.geographic:
325
+ self.arcs.append(lon_arc(w, e))
326
+
327
+ def result(self, crs: str) -> BBox:
328
+ if not self.ys:
329
+ raise ValueError("geometry is empty")
330
+ s, n = min(self.ys), max(self.ys)
331
+ if not self.geographic:
332
+ return BBox(min(self.xs), s, max(self.xs), n, crs=crs)
333
+ w, e = snap_arc_edges(covering_arc(self.arcs), self.xs, self.xs)
334
+ return BBox(w, s, e, n, crs=crs)
335
+
336
+
337
+ def _geojson_bbox(value: Sequence[Any]) -> Tuple[float, float, float, float]:
338
+ vals = [float(v) for v in value]
339
+ if len(vals) == 4:
340
+ return (vals[0], vals[1], vals[2], vals[3])
341
+ if len(vals) == 6:
342
+ return (vals[0], vals[1], vals[3], vals[4])
343
+ raise ValueError(f"a GeoJSON bbox has 4 or 6 numbers, got {len(vals)}")
344
+
345
+
346
+ def _collect_geojson(obj: Any, ext: _Extent) -> None:
347
+ if not isinstance(obj, Mapping):
348
+ raise ValueError("GeoJSON objects must be JSON objects")
349
+ if obj.get("bbox") is not None:
350
+ ext.add_bbox(*_geojson_bbox(obj["bbox"]))
351
+ return
352
+ kind = obj.get("type")
353
+ coords = obj.get("coordinates")
354
+ if kind == "FeatureCollection":
355
+ for feat in obj.get("features") or []:
356
+ _collect_geojson(feat, ext)
357
+ elif kind == "Feature":
358
+ if obj.get("geometry") is not None:
359
+ _collect_geojson(obj["geometry"], ext)
360
+ elif kind == "GeometryCollection":
361
+ for geom in obj.get("geometries") or []:
362
+ _collect_geojson(geom, ext)
363
+ elif kind == "Point":
364
+ if coords:
365
+ ext.add_component([coords])
366
+ elif kind == "MultiPoint":
367
+ for p in coords or []:
368
+ ext.add_component([p])
369
+ elif kind == "LineString":
370
+ ext.add_component(coords or [])
371
+ elif kind == "MultiLineString":
372
+ for line in coords or []:
373
+ ext.add_component(line)
374
+ elif kind == "Polygon":
375
+ if coords:
376
+ ext.add_component(coords[0])
377
+ elif kind == "MultiPolygon":
378
+ for poly in coords or []:
379
+ if poly:
380
+ ext.add_component(poly[0])
381
+ else:
382
+ raise ValueError(f"unsupported GeoJSON type {kind!r}")
383
+
384
+
385
+ def parse_geojson(obj: Any, crs: Optional[CRSLike] = None) -> BBox:
386
+ """Bounding box of any GeoJSON object (text or parsed).
387
+
388
+ A ``bbox`` member is used as is (``west > east`` crosses the antimeridian).
389
+ Otherwise the box is computed from the coordinates: a line or ring whose
390
+ consecutive vertices are more than 180 degrees apart crosses the
391
+ antimeridian, and separate parts are combined with the smallest covering
392
+ longitude range (RFC 7946, section 5.2).
393
+ """
394
+ if isinstance(obj, str):
395
+ obj = json.loads(obj)
396
+ c = normalize_crs(crs) if crs is not None else WGS84
397
+ ext = _Extent(is_geographic(c))
398
+ _collect_geojson(obj, ext)
399
+ return ext.result(c)
400
+
401
+
402
+ def _wkt_points(group: str) -> List[Tuple[float, float]]:
403
+ pts = []
404
+ for chunk in group.split(","):
405
+ nums = _numbers(chunk)
406
+ if len(nums) < 2:
407
+ raise ValueError(f"bad WKT coordinate {chunk.strip()!r}")
408
+ pts.append((nums[0], nums[1]))
409
+ return pts
410
+
411
+
412
+ def parse_wkt(text: str, crs: Optional[CRSLike] = None) -> BBox:
413
+ """Bounding box of WKT/EWKT, ``ENVELOPE(minx, maxx, maxy, miny)`` or ``BOX(...)``.
414
+
415
+ ``SRID=xxxx;`` sets the CRS. Antimeridian handling follows
416
+ :func:`parse_geojson`.
417
+ """
418
+ m = _WKT_RE.match(text)
419
+ if not m:
420
+ raise ValueError(f"{text[:40]!r} is not WKT")
421
+ kind = m.group("kind").upper()
422
+ c = normalize_crs(int(m.group("srid"))) if m.group("srid") else normalize_crs(crs or WGS84)
423
+ body = text[m.end() :]
424
+ if re.search(r"\bEMPTY\b", body, re.IGNORECASE):
425
+ raise ValueError("geometry is empty")
426
+ if kind == "ENVELOPE":
427
+ nums = _numbers(body)
428
+ if len(nums) != 4:
429
+ raise ValueError("ENVELOPE needs 4 numbers: minX, maxX, maxY, minY")
430
+ return BBox(nums[0], nums[3], nums[1], nums[2], crs=c)
431
+ if kind.startswith("BOX"):
432
+ groups = re.findall(r"\(([^()]*)\)", body)
433
+ if len(groups) != 1:
434
+ raise ValueError("BOX needs one coordinate list")
435
+ pts = _wkt_points(groups[0])
436
+ if len(pts) != 2:
437
+ raise ValueError("BOX needs two corners")
438
+ return BBox(pts[0][0], pts[0][1], pts[1][0], pts[1][1], crs=c)
439
+ ext = _Extent(is_geographic(c))
440
+ groups = re.findall(r"\(([^()]*)\)", body)
441
+ if not groups:
442
+ raise ValueError("no coordinates found in WKT")
443
+ if kind == "MULTIPOINT":
444
+ for g in groups:
445
+ for p in _wkt_points(g):
446
+ ext.add_component([p])
447
+ else:
448
+ for g in groups:
449
+ ext.add_component(_wkt_points(g))
450
+ return ext.result(c)
451
+
452
+
453
+ # --------------------------------------------------------------------------
454
+ # Grid cells
455
+ # --------------------------------------------------------------------------
456
+
457
+
458
+ def parse_tile(text: Any) -> BBox:
459
+ """XYZ tile ``"z/x/y"`` (also a tile URL, or a ``(x, y, z)`` Tile)."""
460
+ if isinstance(text, _tiles.Tile):
461
+ return BBox(*text.bounds)
462
+ if not isinstance(text, str):
463
+ raise TypeError("expected 'z/x/y' text or a Tile")
464
+ m = _TILE_RE.match(text) or _TILE_URL_RE.search(text.strip())
465
+ if not m:
466
+ raise ValueError(f"{text!r} is not a z/x/y tile")
467
+ z, x, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
468
+ return BBox(*_tiles.tile_bounds(x, y, z))
469
+
470
+
471
+ def parse_quadkey(text: str) -> BBox:
472
+ """Bing Maps quadkey (digits 0-3)."""
473
+ t = _tiles.quadkey_to_tile(_strip_prefix(text))
474
+ return BBox(*t.bounds)
475
+
476
+
477
+ def parse_geohash(text: str) -> BBox:
478
+ """Geohash cell."""
479
+ return BBox(*_geohash.decode_bounds(_strip_prefix(text)))
480
+
481
+
482
+ def parse_pluscode(text: str) -> BBox:
483
+ """Full Open Location Code (Plus Code) area, e.g. ``"8FVC9G8F+6W"``."""
484
+ return BBox(*_pluscode.decode_bounds(_strip_prefix(text)))
485
+
486
+
487
+ def parse_mgrs(text: str) -> BBox:
488
+ """MGRS grid square, e.g. ``"35TPF"`` (100 km) or ``"35TPF6654"`` (1 km)."""
489
+ return BBox(*_mgrs.square_bounds(_strip_prefix(text)))
490
+
491
+
492
+ def _strip_prefix(text: str) -> str:
493
+ t = text.strip()
494
+ head, sep, tail = t.partition(":")
495
+ return tail.strip() if sep and head.lower() in _PREFIXES else t
496
+
497
+
498
+ # --------------------------------------------------------------------------
499
+ # Dictionaries
500
+ # --------------------------------------------------------------------------
501
+
502
+
503
+ def _lower_keys(obj: Mapping[str, Any]) -> Dict[str, Any]:
504
+ return {str(k).lower(): v for k, v in obj.items()}
505
+
506
+
507
+ def _latlng(obj: Any) -> Tuple[float, float]:
508
+ d = _lower_keys(obj)
509
+ lat = d.get("lat", d.get("latitude"))
510
+ lng = d.get("lng", d.get("lon", d.get("longitude")))
511
+ if lat is None or lng is None:
512
+ raise ValueError("expected an object with lat and lng")
513
+ return (float(lng), float(lat))
514
+
515
+
516
+ def _arcgis_crs(sr: Any) -> Optional[CRSLike]:
517
+ if isinstance(sr, Mapping):
518
+ wkid = sr.get("latestWkid") or sr.get("wkid")
519
+ if wkid:
520
+ return int(wkid)
521
+ return None
522
+
523
+
524
+ def parse_mapping(
525
+ obj: Mapping[str, Any], order: Optional[str] = None, crs: Optional[CRSLike] = None
526
+ ) -> BBox:
527
+ """Dictionaries: GeoJSON, STAC, Nominatim results, ArcGIS extents,
528
+ Leaflet ``{_southWest, _northEast}``, Google ``{south, west, north, east}`` /
529
+ ``{southwest, northeast}`` and ``{minx, miny, maxx, maxy}``-style keys."""
530
+ d = _lower_keys(obj)
531
+ c = crs if crs is not None else WGS84
532
+ if "boundingbox" in d: # Nominatim: [south, north, west, east] as strings
533
+ s, n, w, e = (float(v) for v in d["boundingbox"])
534
+ return BBox(w, s, e, n)
535
+ if "extent" in d and isinstance(d["extent"], Mapping): # STAC collection
536
+ spatial = d["extent"].get("spatial", {})
537
+ boxes = spatial.get("bbox") if isinstance(spatial, Mapping) else None
538
+ if boxes:
539
+ return BBox(*_geojson_bbox(boxes[0]))
540
+ if "type" in d or "bbox" in d or "coordinates" in d:
541
+ return parse_geojson(obj, crs=crs)
542
+ for keys in (
543
+ ("west", "south", "east", "north"),
544
+ ("minx", "miny", "maxx", "maxy"),
545
+ ("xmin", "ymin", "xmax", "ymax"),
546
+ ("minlon", "minlat", "maxlon", "maxlat"),
547
+ ("min_lon", "min_lat", "max_lon", "max_lat"),
548
+ ("left", "bottom", "right", "top"),
549
+ ):
550
+ if all(k in d for k in keys):
551
+ sr = _arcgis_crs(d.get("spatialreference"))
552
+ w, s, e, n = (float(d[k]) for k in keys)
553
+ return BBox(w, s, e, n, crs=sr if sr is not None else c)
554
+ for sw, ne in (("_southwest", "_northeast"), ("southwest", "northeast"), ("sw", "ne")):
555
+ if sw in d and ne in d:
556
+ w, s = _latlng(d[sw])
557
+ e, n = _latlng(d[ne])
558
+ return _build(w, s, e, n, normalize_crs(c), unwrapped=True)
559
+ raise ValueError(f"unrecognised bbox mapping with keys {sorted(d)}")
560
+
561
+
562
+ # --------------------------------------------------------------------------
563
+ # Dispatch
564
+ # --------------------------------------------------------------------------
565
+
566
+
567
+ def _from_json_value(
568
+ value: Any, order: Optional[str], crs: Optional[CRSLike], pair_default: str
569
+ ) -> BBox:
570
+ if isinstance(value, Mapping):
571
+ return parse_mapping(value, order=order, crs=crs)
572
+ if isinstance(value, (list, tuple)):
573
+ if len(value) == 2 and all(isinstance(p, (list, tuple)) for p in value):
574
+ vals = _pairs(value)
575
+ c = normalize_crs(crs if crs is not None else WGS84)
576
+ return _from_four(vals, order, c, default=pair_default, unwrapped=True)
577
+ if len(value) == 6 and order is None:
578
+ return BBox(*_geojson_bbox(value), crs=crs if crs is not None else WGS84)
579
+ return parse_numbers(value, order=order, crs=crs if crs is not None else WGS84)
580
+ raise ValueError(f"cannot read a bbox from {type(value).__name__}")
581
+
582
+
583
+ def _parse_text(
584
+ text: str, order: Optional[str], crs: Optional[CRSLike], viewport: Viewport
585
+ ) -> BBox:
586
+ t = text.strip()
587
+ if not t:
588
+ raise ValueError("empty bbox text")
589
+ head, sep, tail = t.partition(":")
590
+ if sep and head.lower() in _PREFIXES and not tail.startswith("//"):
591
+ return parse(tail.strip(), fmt=head.lower(), order=order, crs=crs, viewport=viewport)
592
+ if t[0] in "[{":
593
+ try:
594
+ value = json.loads(t)
595
+ except ValueError:
596
+ value = None
597
+ if value is not None:
598
+ return _from_json_value(value, order, crs, pair_default="swne")
599
+ low = t.lower()
600
+ if "bboxfinder.com" in low:
601
+ return parse_bboxfinder(t)
602
+ if _OVERPASS_RE.match(t):
603
+ return parse_overpass(t)
604
+ if _GDAL_RE.search(" " + t):
605
+ return parse_gdal(t, crs=crs)
606
+ if _POSTGIS_RE.match(t):
607
+ return parse_postgis(t, crs=crs)
608
+ if _WKT_RE.match(t):
609
+ return parse_wkt(t, crs=crs)
610
+ if re.match(r"^[a-z][a-z0-9+.-]*://", low) or low.startswith(("www.", "?", "#")):
611
+ if re.search(r"(?:[?#&]|^)(?:bbox|minlon|map)=", low):
612
+ return parse_osm_url(t, viewport=viewport)
613
+ if _TILE_URL_RE.search(t):
614
+ return parse_tile(t)
615
+ raise ValueError(f"no bounding box found in URL {t!r}")
616
+ if _TILE_RE.match(t):
617
+ return parse_tile(t)
618
+ if _PLUS_RE.match(t):
619
+ return parse_pluscode(t)
620
+ if _mgrs._PATTERN.match(t):
621
+ return parse_mgrs(t)
622
+ if (_NUMS_ONLY.match(t) and len(_NUM_RE.findall(t)) > 1) or _CRS_TAIL.match(t):
623
+ return parse_numbers(t, order=order, crs=crs if crs is not None else WGS84)
624
+ if _QUADKEY_RE.match(t):
625
+ return parse_quadkey(t)
626
+ if _GEOHASH_RE.match(t):
627
+ return parse_geohash(t)
628
+ raise ValueError(
629
+ f"could not recognise {t[:60]!r} as a bounding box. Place names need an explicit "
630
+ "lookup: bboxkit.nominatim.geocode_bbox(...)"
631
+ )
632
+
633
+
634
+ def parse(
635
+ value: Any,
636
+ *,
637
+ fmt: Optional[str] = None,
638
+ order: Optional[str] = None,
639
+ crs: Optional[CRSLike] = None,
640
+ viewport: Viewport = (1024, 768),
641
+ ) -> BBox:
642
+ """Parse a bounding box from (almost) anything.
643
+
644
+ Accepts a :class:`BBox`, a string in any supported format (detected
645
+ automatically unless ``fmt`` names one of :data:`PARSERS`), a GeoJSON-like
646
+ or other bbox dictionary, an object with ``__geo_interface__``, a Shapely
647
+ or GeoPandas object (``.bounds`` / ``.total_bounds``), a :class:`Tile`, or
648
+ a sequence of four numbers. Never touches the network.
649
+
650
+ ``order`` (``"wsen"`` or ``"swne"``) settles four-number inputs; without it
651
+ the order is guessed and an :class:`AmbiguousOrderWarning` is emitted when
652
+ the guess could be wrong.
653
+ """
654
+ if isinstance(value, BBox):
655
+ return value if crs is None else value.to_crs(crs)
656
+ if isinstance(value, bytes):
657
+ value = value.decode("utf-8")
658
+ if fmt is not None:
659
+ name = fmt.lower().replace("_", "-")
660
+ if name in ("wsen", "swne") and order is None:
661
+ order = name
662
+ key = _PREFIXES.get(name, name)
663
+ if key not in PARSERS:
664
+ raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(sorted(PARSERS))}")
665
+ if key == "numbers":
666
+ return parse_numbers(value, order=order, crs=crs if crs is not None else WGS84)
667
+ if key in ("osm",):
668
+ return parse_osm_url(value, viewport=viewport)
669
+ if key in ("wkt", "geojson", "gdal", "postgis"):
670
+ return PARSERS[key](value, crs=crs)
671
+ return PARSERS[key](value)
672
+ if isinstance(value, str):
673
+ return _parse_text(value, order, crs, viewport)
674
+ if isinstance(value, _tiles.Tile):
675
+ return parse_tile(value)
676
+ for attr in ("total_bounds", "bounds"):
677
+ bounds = getattr(value, attr, None)
678
+ if bounds is not None and not callable(bounds):
679
+ vals = [float(v) for v in bounds]
680
+ if len(vals) == 4 and all(math.isfinite(v) for v in vals):
681
+ return BBox(
682
+ vals[0], vals[1], vals[2], vals[3], crs=crs if crs is not None else WGS84
683
+ )
684
+ geo = getattr(value, "__geo_interface__", None)
685
+ if isinstance(geo, Mapping):
686
+ return parse_geojson(geo, crs=crs)
687
+ return _from_json_value(value, order, crs, pair_default="swne")
688
+
689
+
690
+ PARSERS: Dict[str, Callable[..., BBox]] = {
691
+ "numbers": parse_numbers,
692
+ "bboxfinder": parse_bboxfinder,
693
+ "overpass": parse_overpass,
694
+ "osm": parse_osm_url,
695
+ "wkt": parse_wkt,
696
+ "geojson": parse_geojson,
697
+ "gdal": parse_gdal,
698
+ "postgis": parse_postgis,
699
+ "leaflet": parse_leaflet,
700
+ "mapbox": parse_mapbox,
701
+ "tile": parse_tile,
702
+ "quadkey": parse_quadkey,
703
+ "geohash": parse_geohash,
704
+ "pluscode": parse_pluscode,
705
+ "mgrs": parse_mgrs,
706
+ }
707
+ """Explicit parsers by format name (the ``fmt=`` argument of :func:`parse`)."""
708
+
709
+ _PREFIXES: Dict[str, str] = {name: name for name in PARSERS}
710
+ _PREFIXES.update(
711
+ {
712
+ "wsen": "numbers",
713
+ "swne": "numbers",
714
+ "xyz": "tile",
715
+ "plus": "pluscode",
716
+ "olc": "pluscode",
717
+ "gdal-te": "gdal",
718
+ "gdal-projwin": "gdal",
719
+ "envelope": "wkt",
720
+ "osm-url": "osm",
721
+ }
722
+ )