1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2025 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Complex datatypes used by the Nominatim API.
10 from typing import Optional, Union, Tuple, NamedTuple, TypeVar, Type, Dict, \
11 Any, List, Sequence, TYPE_CHECKING
12 from collections import abc
17 from struct import unpack
18 from binascii import unhexlify
21 from .localization import Locales
22 from .errors import UsageError
25 @dataclasses.dataclass
27 """ Reference a place by Nominatim's internal ID.
29 A PlaceID may reference place from the main table placex, from
30 the interpolation tables or the postcode tables. Place IDs are not
31 stable between installations. You may use this type theefore only
32 with place IDs obtained from the same database.
36 The internal ID of the place to reference.
39 def __str__(self) -> str:
40 return str(self.place_id)
43 @dataclasses.dataclass
45 """ Reference a place by its OSM ID and potentially the basic category.
47 The OSM ID may refer to places in the main table placex and OSM
51 """ OSM type of the object. Must be one of `N`(node), `W`(way) or
55 """ The OSM ID of the object.
57 osm_class: Optional[str] = None
58 """ The same OSM object may appear multiple times in the database under
59 different categories. The optional class parameter allows to distinguish
60 the different categories and corresponds to the key part of the category.
61 If there are multiple objects in the database and `osm_class` is
62 left out, then one of the objects is returned at random.
65 def __str__(self) -> str:
66 return f"{self.osm_type}{self.osm_id}"
68 def __post_init__(self) -> None:
69 if self.osm_type not in ('N', 'W', 'R'):
70 raise ValueError(f"Illegal OSM type '{self.osm_type}'. Must be one of N, W, R.")
72 def class_as_housenumber(self) -> Optional[int]:
73 """ Interpret the class property as a housenumber and return it.
75 If the OSM ID points to an interpolation, then the class may be
76 a number pointing to the exact number requested. This function
77 returns the housenumber as an int, if class is set and is a number.
79 if self.osm_class and self.osm_class.isdigit():
80 return int(self.osm_class)
84 PlaceRef = Union[PlaceID, OsmID]
87 class Point(NamedTuple):
88 """ A geographic point in WGS84 projection.
94 def lat(self) -> float:
95 """ Return the latitude of the point.
100 def lon(self) -> float:
101 """ Return the longitude of the point.
105 def to_geojson(self) -> str:
106 """ Return the point in GeoJSON format.
108 return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
111 def from_wkb(wkb: Union[str, bytes]) -> 'Point':
112 """ Create a point from EWKB as returned from the database.
114 if isinstance(wkb, str):
117 raise ValueError(f"Point wkb has unexpected length {len(wkb)}")
119 gtype, srid, x, y = unpack('>iidd', wkb[1:])
121 gtype, srid, x, y = unpack('<iidd', wkb[1:])
123 raise ValueError("WKB has unknown endian value.")
125 if gtype != 0x20000001:
126 raise ValueError("WKB must be a point geometry.")
128 raise ValueError("Only WGS84 WKB supported.")
133 def from_param(inp: Any) -> 'Point':
134 """ Create a point from an input parameter. The parameter
135 may be given as a point, a string or a sequence of
136 strings or floats. Raises a UsageError if the format is
139 if isinstance(inp, Point):
143 if isinstance(inp, str):
145 elif isinstance(inp, abc.Sequence):
149 raise UsageError('Point parameter needs 2 coordinates.')
151 x, y = filter(math.isfinite, map(float, seq))
152 except ValueError as exc:
153 raise UsageError('Point parameter needs to be numbers.') from exc
155 if not -180 <= x <= 180 or not -90 <= y <= 90.0:
156 raise UsageError('Point coordinates invalid.')
160 def to_wkt(self) -> str:
161 """ Return the WKT representation of the point.
163 return f'POINT({self.x} {self.y})'
166 AnyPoint = Union[Point, Tuple[float, float]]
168 WKB_BBOX_HEADER_LE = b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'
169 WKB_BBOX_HEADER_BE = b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'
173 """ A bounding box in WGS84 projection.
175 The coordinates are available as an array in the 'coord'
176 property in the order (minx, miny, maxx, maxy).
178 def __init__(self, minx: float, miny: float, maxx: float, maxy: float) -> None:
179 """ Create a new bounding box with the given coordinates in WGS84
182 self.coords = (minx, miny, maxx, maxy)
185 def minlat(self) -> float:
186 """ Southern-most latitude, corresponding to the minimum y coordinate.
188 return self.coords[1]
191 def maxlat(self) -> float:
192 """ Northern-most latitude, corresponding to the maximum y coordinate.
194 return self.coords[3]
197 def minlon(self) -> float:
198 """ Western-most longitude, corresponding to the minimum x coordinate.
200 return self.coords[0]
203 def maxlon(self) -> float:
204 """ Eastern-most longitude, corresponding to the maximum x coordinate.
206 return self.coords[2]
209 def area(self) -> float:
210 """ Return the area of the box in WGS84.
212 return (self.coords[2] - self.coords[0]) * (self.coords[3] - self.coords[1])
214 def contains(self, pt: Point) -> bool:
215 """ Check if the point is inside or on the boundary of the box.
217 return self.coords[0] <= pt[0] and self.coords[1] <= pt[1]\
218 and self.coords[2] >= pt[0] and self.coords[3] >= pt[1]
220 def to_wkt(self) -> str:
221 """ Return the WKT representation of the Bbox. This
222 is a simple polygon with four points.
224 return 'POLYGON(({0} {1},{0} {3},{2} {3},{2} {1},{0} {1}))'\
225 .format(*self.coords)
228 def from_wkb(wkb: Union[None, str, bytes]) -> 'Optional[Bbox]':
229 """ Create a Bbox from a bounding box polygon as returned by
230 the database. Returns `None` if the input value is None.
235 if isinstance(wkb, str):
239 raise ValueError("WKB must be a bounding box polygon")
240 if wkb.startswith(WKB_BBOX_HEADER_LE):
241 x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65])
242 elif wkb.startswith(WKB_BBOX_HEADER_BE):
243 x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65])
245 raise ValueError("WKB has wrong header")
247 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
250 def from_point(pt: Point, buffer: float) -> 'Bbox':
251 """ Return a Bbox around the point with the buffer added to all sides.
253 return Bbox(pt[0] - buffer, pt[1] - buffer,
254 pt[0] + buffer, pt[1] + buffer)
257 def from_param(inp: Any) -> 'Bbox':
258 """ Return a Bbox from an input parameter. The box may be
259 given as a Bbox, a string or a list or strings or integer.
260 Raises a UsageError if the format is incorrect.
262 if isinstance(inp, Bbox):
266 if isinstance(inp, str):
268 elif isinstance(inp, abc.Sequence):
272 raise UsageError('Bounding box parameter needs 4 coordinates.')
274 x1, y1, x2, y2 = filter(math.isfinite, map(float, seq))
275 except ValueError as exc:
276 raise UsageError('Bounding box parameter needs to be numbers.') from exc
278 x1 = min(180, max(-180, x1))
279 x2 = min(180, max(-180, x2))
280 y1 = min(90, max(-90, y1))
281 y2 = min(90, max(-90, y2))
283 if x1 == x2 or y1 == y2:
284 raise UsageError('Bounding box with invalid parameters.')
286 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
289 class GeometryFormat(enum.Flag):
290 """ All search functions support returning the full geometry of a place in
291 various formats. The internal geometry is converted by PostGIS to
292 the desired format and then returned as a string. It is possible to
293 request multiple formats at the same time.
296 """ No geometry requested. Alias for a empty flag.
298 GEOJSON = enum.auto()
300 [GeoJSON](https://geojson.org/) format
304 [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) format
308 [SVG](http://www.w3.org/TR/SVG/paths.html) format
312 [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) format
316 class DataLayer(enum.Flag):
317 """ The `DataLayer` flag type defines the layers that can be selected
318 for reverse and forward search.
320 ADDRESS = enum.auto()
321 """ The address layer contains all places relevant for addresses:
322 fully qualified addresses with a house number (or a house name equivalent,
323 for some addresses) and places that can be part of an address like
324 roads, cities, states.
327 """ Layer for points of interest like shops, restaurants but also
328 recycling bins or postboxes.
330 RAILWAY = enum.auto()
331 """ Layer with railway features including tracks and other infrastructure.
332 Note that in Nominatim's standard configuration, only very few railway
333 features are imported into the database. Thus a custom configuration
334 is required to make full use of this layer.
336 NATURAL = enum.auto()
337 """ Layer with natural features like rivers, lakes and mountains.
339 MANMADE = enum.auto()
340 """ Layer with other human-made features and boundaries. This layer is
341 the catch-all and includes all features not covered by the other
342 layers. A typical example for this layer are national park boundaries.
346 class QueryStatistics(dict[str, Any]):
347 """ A specialised dictionary for collecting query statistics.
350 def __enter__(self) -> 'QueryStatistics':
351 self.log_time('start')
354 def __exit__(self, *_: Any) -> None:
356 self['total_time'] = (self['end'] - self['start']).total_seconds()
357 if 'start_query' in self:
358 self['wait_time'] = (self['start_query'] - self['start']).total_seconds()
360 self['wait_time'] = self['total_time']
361 self['start_query'] = self['end']
362 self['query_time'] = self['total_time'] - self['wait_time']
364 def __missing__(self, key: str) -> str:
367 def log_time(self, key: str) -> None:
368 self[key] = dt.datetime.now(tz=dt.timezone.utc)
372 """ Null object to use, when no query statistics are requested.
375 def __enter__(self) -> 'NoQueryStats':
378 def __exit__(self, *_: Any) -> None:
381 def __setitem__(self, key: str, value: Any) -> None:
384 def __getitem__(self, key: str) -> Any:
387 def __contains__(self, key: str, default: Any = None) -> bool:
390 def log_time(self, key: str) -> None:
394 def format_country(cc: Any) -> List[str]:
395 """ Extract a list of country codes from the input which may be either
396 a string or list of strings. Filters out all values that are not
400 if isinstance(cc, str):
401 clist = cc.split(',')
402 elif isinstance(cc, abc.Sequence):
405 raise UsageError("Parameter 'country' needs to be a comma-separated list "
406 "or a Python list of strings.")
408 return [cc.lower() for cc in clist if isinstance(cc, str) and len(cc) == 2]
411 def format_excluded(ids: Any) -> List[PlaceRef]:
412 """ Extract a list of place IDs and OSM IDs from the input.
418 if isinstance(ids, str):
419 plist = [s.strip() for s in ids.split(',')]
420 elif isinstance(ids, abc.Sequence):
423 raise UsageError("Parameter 'excluded' needs to be a comma-separated list "
424 "or a Python list of place IDs or OSM IDs.")
426 result: List[PlaceRef] = []
430 if isinstance(i, (PlaceID, OsmID)):
432 elif isinstance(i, int):
434 result.append(PlaceID(i))
435 elif isinstance(i, str):
438 result.append(PlaceID(int(i)))
439 elif len(i) > 1 and i[0].upper() in ('N', 'W', 'R') and i[1:].isdigit():
441 result.append(OsmID(i[0].upper(), int(i[1:])))
443 raise UsageError(f"Invalid exclude ID: {i}")
445 raise UsageError("Parameter 'excluded' contains invalid types.")
450 def format_categories(categories: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
451 """ Extract a list of categories. Currently a noop.
456 TParam = TypeVar('TParam', bound='LookupDetails')
459 @dataclasses.dataclass
461 """ Collection of parameters that define which kind of details are
462 returned with a lookup or details result.
464 geometry_output: GeometryFormat = GeometryFormat.NONE
465 """ Add the full geometry of the place to the result. Multiple
466 formats may be selected. Note that geometries can become quite large.
468 address_details: bool = False
469 """ Get detailed information on the places that make up the address
472 linked_places: bool = False
473 """ Get detailed information on the places that link to the result.
475 parented_places: bool = False
476 """ Get detailed information on all places that this place is a parent
477 for, i.e. all places for which it provides the address details.
478 Only POI places can have parents.
480 entrances: bool = False
481 """ Get detailed information about the tagged entrances for the result.
483 keywords: bool = False
484 """ Add information about the search terms used for this place.
486 geometry_simplification: float = 0.0
487 """ Simplification factor for a geometry in degrees WGS. A factor of
488 0.0 means the original geometry is kept. The higher the value, the
489 more the geometry gets simplified.
491 query_stats: Union[QueryStatistics, NoQueryStats] = \
492 dataclasses.field(default_factory=NoQueryStats)
493 """ Optional QueryStatistics object collecting information about
494 runtime behaviour of the call.
498 def from_kwargs(cls: Type[TParam], kwargs: Dict[str, Any]) -> TParam:
499 """ Load the data fields of the class from a dictionary.
500 Unknown entries in the dictionary are ignored, missing ones
501 get the default setting.
503 The function supports type checking and throws a UsageError
504 when the value does not fit.
506 def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
508 return field.default_factory() \
509 if field.default_factory != dataclasses.MISSING \
511 if field.metadata and 'transform' in field.metadata:
512 return field.metadata['transform'](v)
513 if not isinstance(v, field.type): # type: ignore[arg-type]
514 raise UsageError(f"Parameter '{field.name}' needs to be of {field.type!s}.")
517 return cls(**{f.name: _check_field(kwargs[f.name], f)
518 for f in dataclasses.fields(cls) if f.name in kwargs})
521 @dataclasses.dataclass
522 class ReverseDetails(LookupDetails):
523 """ Collection of parameters for the reverse call.
526 max_rank: int = dataclasses.field(default=30,
527 metadata={'transform': lambda v: max(0, min(v, 30))})
528 """ Highest address rank to return.
531 layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
532 """ Filter which kind of data to include.
536 @dataclasses.dataclass
537 class SearchDetails(LookupDetails):
538 """ Collection of parameters for the search call.
540 max_results: int = 10
541 """ Maximum number of results to be returned. The actual number of results
545 min_rank: int = dataclasses.field(default=0,
546 metadata={'transform': lambda v: max(0, min(v, 30))})
547 """ Lowest address rank to return.
550 max_rank: int = dataclasses.field(default=30,
551 metadata={'transform': lambda v: max(0, min(v, 30))})
552 """ Highest address rank to return.
555 layers: Optional[DataLayer] = dataclasses.field(default=None,
556 metadata={'transform': lambda r: r})
557 """ Filter which kind of data to include. When 'None' (the default) then
558 filtering by layers is disabled.
561 countries: List[str] = dataclasses.field(default_factory=list,
562 metadata={'transform': format_country})
563 """ Restrict search results to the given countries. An empty list (the
564 default) will disable this filter.
567 excluded: List[PlaceRef] = dataclasses.field(default_factory=list,
568 metadata={'transform': format_excluded})
569 """ List of OSM objects to exclude from the results,
570 provided as either internal Place IDs or OSM IDs.
573 viewbox: Optional[Bbox] = dataclasses.field(default=None,
574 metadata={'transform': Bbox.from_param})
575 """ Focus the search on a given map area.
578 bounded_viewbox: bool = False
579 """ Use 'viewbox' as a filter and restrict results to places within the
583 near: Optional[Point] = dataclasses.field(default=None,
584 metadata={'transform': Point.from_param})
585 """ Order results by distance to the given point.
588 near_radius: Optional[float] = dataclasses.field(default=None,
589 metadata={'transform': lambda r: r})
590 """ Use near point as a filter and drop results outside the given
591 radius. Radius is given in degrees WSG84.
594 categories: List[Tuple[str, str]] = dataclasses.field(default_factory=list,
595 metadata={'transform': format_categories})
596 """ Restrict search to places with one of the given class/type categories.
597 An empty list (the default) will disable this filter.
600 viewbox_x2: Optional[Bbox] = None
602 locales: Optional['Locales'] = dataclasses.field(
603 default=None, metadata={'transform': lambda v: v})
604 """ Locale preferences of the caller.
605 Used during result re-ranking to prefer results that match the
606 caller's locale over results that only match in an alternate language.
609 def __post_init__(self) -> None:
610 if self.viewbox is not None:
611 xext = (self.viewbox.maxlon - self.viewbox.minlon)/2
612 yext = (self.viewbox.maxlat - self.viewbox.minlat)/2
613 self.viewbox_x2 = Bbox(self.viewbox.minlon - xext, self.viewbox.minlat - yext,
614 self.viewbox.maxlon + xext, self.viewbox.maxlat + yext)
617 def excluded_place_ids(self) -> List[int]:
618 """ Return excluded entries as a plain list of place ID integers.
619 Only includes PlaceID entries. Returns [0] if empty to
620 ensure SQL NOT IN clauses work correctly.
622 return [e.place_id for e in self.excluded if isinstance(e, PlaceID)] or [0]
624 def restrict_min_max_rank(self, new_min: int, new_max: int) -> None:
625 """ Change the min_rank and max_rank fields to respect the
628 assert new_min <= new_max
629 self.min_rank = max(self.min_rank, new_min)
630 self.max_rank = min(self.max_rank, new_max)
632 def is_impossible(self) -> bool:
633 """ Check if the parameter configuration is contradictionary and
634 cannot yield any results.
636 return (self.min_rank > self.max_rank
637 or (self.bounded_viewbox
638 and self.viewbox is not None and self.near is not None
639 and self.viewbox.contains(self.near))
640 or (self.layers is not None and not self.layers)
641 or (self.max_rank <= 4 and
642 self.layers is not None and not self.layers & DataLayer.ADDRESS))
644 def layer_enabled(self, layer: DataLayer) -> bool:
645 """ Check if the given layer has been chosen. Also returns
646 true when layer restriction has been disabled completely.
648 return self.layers is None or bool(self.layers & layer)
651 @dataclasses.dataclass
652 class EntranceDetails:
653 """ Reference a place by its OSM ID and potentially the basic category.
655 The OSM ID may refer to places in the main table placex and OSM
659 """ The OSM ID of the object.
662 """ The value of the OSM entrance tag (i.e. yes, main, secondary, etc.).
665 """ The location of the entrance node.
667 extratags: Dict[str, str]
668 """ The other tags associated with the entrance node.