]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/types.py
Locales and localization refactor with Locales as a localizer object.
[nominatim.git] / src / nominatim_api / types.py
1 # SPDX-License-Identifier: GPL-3.0-or-later
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Complex datatypes used by the Nominatim API.
9 """
10 from typing import Optional, Union, Tuple, NamedTuple, TypeVar, Type, Dict, \
11                    Any, List, Sequence
12 from collections import abc
13 import dataclasses
14 import enum
15 import math
16 from struct import unpack
17 from binascii import unhexlify
18
19 from .errors import UsageError
20
21
22 @dataclasses.dataclass
23 class PlaceID:
24     """ Reference a place by Nominatim's internal ID.
25
26         A PlaceID may reference place from the main table placex, from
27         the interpolation tables or the postcode tables. Place IDs are not
28         stable between installations. You may use this type theefore only
29         with place IDs obtained from the same database.
30     """
31     place_id: int
32     """
33     The internal ID of the place to reference.
34     """
35
36
37 @dataclasses.dataclass
38 class OsmID:
39     """ Reference a place by its OSM ID and potentially the basic category.
40
41         The OSM ID may refer to places in the main table placex and OSM
42         interpolation lines.
43     """
44     osm_type: str
45     """ OSM type of the object. Must be one of `N`(node), `W`(way) or
46         `R`(relation).
47     """
48     osm_id: int
49     """ The OSM ID of the object.
50     """
51     osm_class: Optional[str] = None
52     """ The same OSM object may appear multiple times in the database under
53         different categories. The optional class parameter allows to distinguish
54         the different categories and corresponds to the key part of the category.
55         If there are multiple objects in the database and `osm_class` is
56         left out, then one of the objects is returned at random.
57     """
58
59     def __post_init__(self) -> None:
60         if self.osm_type not in ('N', 'W', 'R'):
61             raise ValueError(f"Illegal OSM type '{self.osm_type}'. Must be one of N, W, R.")
62
63     def class_as_housenumber(self) -> Optional[int]:
64         """ Interpret the class property as a housenumber and return it.
65
66             If the OSM ID points to an interpolation, then the class may be
67             a number pointing to the exact number requested. This function
68             returns the housenumber as an int, if class is set and is a number.
69         """
70         if self.osm_class and self.osm_class.isdigit():
71             return int(self.osm_class)
72         return None
73
74
75 PlaceRef = Union[PlaceID, OsmID]
76
77
78 class Point(NamedTuple):
79     """ A geographic point in WGS84 projection.
80     """
81     x: float
82     y: float
83
84     @property
85     def lat(self) -> float:
86         """ Return the latitude of the point.
87         """
88         return self.y
89
90     @property
91     def lon(self) -> float:
92         """ Return the longitude of the point.
93         """
94         return self.x
95
96     def to_geojson(self) -> str:
97         """ Return the point in GeoJSON format.
98         """
99         return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
100
101     @staticmethod
102     def from_wkb(wkb: Union[str, bytes]) -> 'Point':
103         """ Create a point from EWKB as returned from the database.
104         """
105         if isinstance(wkb, str):
106             wkb = unhexlify(wkb)
107         if len(wkb) != 25:
108             raise ValueError(f"Point wkb has unexpected length {len(wkb)}")
109         if wkb[0] == 0:
110             gtype, srid, x, y = unpack('>iidd', wkb[1:])
111         elif wkb[0] == 1:
112             gtype, srid, x, y = unpack('<iidd', wkb[1:])
113         else:
114             raise ValueError("WKB has unknown endian value.")
115
116         if gtype != 0x20000001:
117             raise ValueError("WKB must be a point geometry.")
118         if srid != 4326:
119             raise ValueError("Only WGS84 WKB supported.")
120
121         return Point(x, y)
122
123     @staticmethod
124     def from_param(inp: Any) -> 'Point':
125         """ Create a point from an input parameter. The parameter
126             may be given as a point, a string or a sequence of
127             strings or floats. Raises a UsageError if the format is
128             not correct.
129         """
130         if isinstance(inp, Point):
131             return inp
132
133         seq: Sequence[str]
134         if isinstance(inp, str):
135             seq = inp.split(',')
136         elif isinstance(inp, abc.Sequence):
137             seq = inp
138
139         if len(seq) != 2:
140             raise UsageError('Point parameter needs 2 coordinates.')
141         try:
142             x, y = filter(math.isfinite, map(float, seq))
143         except ValueError as exc:
144             raise UsageError('Point parameter needs to be numbers.') from exc
145
146         if not -180 <= x <= 180 or not -90 <= y <= 90.0:
147             raise UsageError('Point coordinates invalid.')
148
149         return Point(x, y)
150
151     def to_wkt(self) -> str:
152         """ Return the WKT representation of the point.
153         """
154         return f'POINT({self.x} {self.y})'
155
156
157 AnyPoint = Union[Point, Tuple[float, float]]
158
159 WKB_BBOX_HEADER_LE = b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'
160 WKB_BBOX_HEADER_BE = b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'
161
162
163 class Bbox:
164     """ A bounding box in WGS84 projection.
165
166         The coordinates are available as an array in the 'coord'
167         property in the order (minx, miny, maxx, maxy).
168     """
169     def __init__(self, minx: float, miny: float, maxx: float, maxy: float) -> None:
170         """ Create a new bounding box with the given coordinates in WGS84
171             projection.
172         """
173         self.coords = (minx, miny, maxx, maxy)
174
175     @property
176     def minlat(self) -> float:
177         """ Southern-most latitude, corresponding to the minimum y coordinate.
178         """
179         return self.coords[1]
180
181     @property
182     def maxlat(self) -> float:
183         """ Northern-most latitude, corresponding to the maximum y coordinate.
184         """
185         return self.coords[3]
186
187     @property
188     def minlon(self) -> float:
189         """ Western-most longitude, corresponding to the minimum x coordinate.
190         """
191         return self.coords[0]
192
193     @property
194     def maxlon(self) -> float:
195         """ Eastern-most longitude, corresponding to the maximum x coordinate.
196         """
197         return self.coords[2]
198
199     @property
200     def area(self) -> float:
201         """ Return the area of the box in WGS84.
202         """
203         return (self.coords[2] - self.coords[0]) * (self.coords[3] - self.coords[1])
204
205     def contains(self, pt: Point) -> bool:
206         """ Check if the point is inside or on the boundary of the box.
207         """
208         return self.coords[0] <= pt[0] and self.coords[1] <= pt[1]\
209             and self.coords[2] >= pt[0] and self.coords[3] >= pt[1]
210
211     def to_wkt(self) -> str:
212         """ Return the WKT representation of the Bbox. This
213             is a simple polygon with four points.
214         """
215         return 'POLYGON(({0} {1},{0} {3},{2} {3},{2} {1},{0} {1}))'\
216             .format(*self.coords)
217
218     @staticmethod
219     def from_wkb(wkb: Union[None, str, bytes]) -> 'Optional[Bbox]':
220         """ Create a Bbox from a bounding box polygon as returned by
221             the database. Returns `None` if the input value is None.
222         """
223         if wkb is None:
224             return None
225
226         if isinstance(wkb, str):
227             wkb = unhexlify(wkb)
228
229         if len(wkb) != 97:
230             raise ValueError("WKB must be a bounding box polygon")
231         if wkb.startswith(WKB_BBOX_HEADER_LE):
232             x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65])
233         elif wkb.startswith(WKB_BBOX_HEADER_BE):
234             x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65])
235         else:
236             raise ValueError("WKB has wrong header")
237
238         return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
239
240     @staticmethod
241     def from_point(pt: Point, buffer: float) -> 'Bbox':
242         """ Return a Bbox around the point with the buffer added to all sides.
243         """
244         return Bbox(pt[0] - buffer, pt[1] - buffer,
245                     pt[0] + buffer, pt[1] + buffer)
246
247     @staticmethod
248     def from_param(inp: Any) -> 'Bbox':
249         """ Return a Bbox from an input parameter. The box may be
250             given as a Bbox, a string or a list or strings or integer.
251             Raises a UsageError if the format is incorrect.
252         """
253         if isinstance(inp, Bbox):
254             return inp
255
256         seq: Sequence[str]
257         if isinstance(inp, str):
258             seq = inp.split(',')
259         elif isinstance(inp, abc.Sequence):
260             seq = inp
261
262         if len(seq) != 4:
263             raise UsageError('Bounding box parameter needs 4 coordinates.')
264         try:
265             x1, y1, x2, y2 = filter(math.isfinite, map(float, seq))
266         except ValueError as exc:
267             raise UsageError('Bounding box parameter needs to be numbers.') from exc
268
269         x1 = min(180, max(-180, x1))
270         x2 = min(180, max(-180, x2))
271         y1 = min(90, max(-90, y1))
272         y2 = min(90, max(-90, y2))
273
274         if x1 == x2 or y1 == y2:
275             raise UsageError('Bounding box with invalid parameters.')
276
277         return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
278
279
280 class GeometryFormat(enum.Flag):
281     """ All search functions support returning the full geometry of a place in
282         various formats. The internal geometry is converted by PostGIS to
283         the desired format and then returned as a string. It is possible to
284         request multiple formats at the same time.
285     """
286     NONE = 0
287     """ No geometry requested. Alias for a empty flag.
288     """
289     GEOJSON = enum.auto()
290     """
291     [GeoJSON](https://geojson.org/) format
292     """
293     KML = enum.auto()
294     """
295     [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) format
296     """
297     SVG = enum.auto()
298     """
299     [SVG](http://www.w3.org/TR/SVG/paths.html) format
300     """
301     TEXT = enum.auto()
302     """
303     [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) format
304     """
305
306
307 class DataLayer(enum.Flag):
308     """ The `DataLayer` flag type defines the layers that can be selected
309         for reverse and forward search.
310     """
311     ADDRESS = enum.auto()
312     """ The address layer contains all places relevant for addresses:
313         fully qualified addresses with a house number (or a house name equivalent,
314         for some addresses) and places that can be part of an address like
315         roads, cities, states.
316     """
317     POI = enum.auto()
318     """ Layer for points of interest like shops, restaurants but also
319         recycling bins or postboxes.
320     """
321     RAILWAY = enum.auto()
322     """ Layer with railway features including tracks and other infrastructure.
323         Note that in Nominatim's standard configuration, only very few railway
324         features are imported into the database. Thus a custom configuration
325         is required to make full use of this layer.
326     """
327     NATURAL = enum.auto()
328     """ Layer with natural features like rivers, lakes and mountains.
329     """
330     MANMADE = enum.auto()
331     """ Layer with other human-made features and boundaries. This layer is
332         the catch-all and includes all features not covered by the other
333         layers. A typical example for this layer are national park boundaries.
334     """
335
336
337 def format_country(cc: Any) -> List[str]:
338     """ Extract a list of country codes from the input which may be either
339         a string or list of strings. Filters out all values that are not
340         a two-letter string.
341     """
342     clist: Sequence[str]
343     if isinstance(cc, str):
344         clist = cc.split(',')
345     elif isinstance(cc, abc.Sequence):
346         clist = cc
347     else:
348         raise UsageError("Parameter 'country' needs to be a comma-separated list "
349                          "or a Python list of strings.")
350
351     return [cc.lower() for cc in clist if isinstance(cc, str) and len(cc) == 2]
352
353
354 def format_excluded(ids: Any) -> List[int]:
355     """ Extract a list of place ids from the input which may be either
356         a string or a list of strings or ints. Ignores empty value but
357         throws a UserError on anything that cannot be converted to int.
358     """
359     plist: Sequence[str]
360     if isinstance(ids, str):
361         plist = [s.strip() for s in ids.split(',')]
362     elif isinstance(ids, abc.Sequence):
363         plist = ids
364     else:
365         raise UsageError("Parameter 'excluded' needs to be a comma-separated list "
366                          "or a Python list of numbers.")
367     if not all(isinstance(i, int) or
368                (isinstance(i, str) and (not i or i.isdigit())) for i in plist):
369         raise UsageError("Parameter 'excluded' only takes place IDs.")
370
371     return [int(id) for id in plist if id] or [0]
372
373
374 def format_categories(categories: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
375     """ Extract a list of categories. Currently a noop.
376     """
377     return categories
378
379
380 TParam = TypeVar('TParam', bound='LookupDetails')
381
382
383 @dataclasses.dataclass
384 class LookupDetails:
385     """ Collection of parameters that define which kind of details are
386         returned with a lookup or details result.
387     """
388     geometry_output: GeometryFormat = GeometryFormat.NONE
389     """ Add the full geometry of the place to the result. Multiple
390         formats may be selected. Note that geometries can become quite large.
391     """
392     address_details: bool = False
393     """ Get detailed information on the places that make up the address
394         for the result.
395     """
396     linked_places: bool = False
397     """ Get detailed information on the places that link to the result.
398     """
399     parented_places: bool = False
400     """ Get detailed information on all places that this place is a parent
401         for, i.e. all places for which it provides the address details.
402         Only POI places can have parents.
403     """
404     keywords: bool = False
405     """ Add information about the search terms used for this place.
406     """
407     geometry_simplification: float = 0.0
408     """ Simplification factor for a geometry in degrees WGS. A factor of
409         0.0 means the original geometry is kept. The higher the value, the
410         more the geometry gets simplified.
411     """
412
413     @classmethod
414     def from_kwargs(cls: Type[TParam], kwargs: Dict[str, Any]) -> TParam:
415         """ Load the data fields of the class from a dictionary.
416             Unknown entries in the dictionary are ignored, missing ones
417             get the default setting.
418
419             The function supports type checking and throws a UsageError
420             when the value does not fit.
421         """
422         def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
423             if v is None:
424                 return field.default_factory() \
425                        if field.default_factory != dataclasses.MISSING \
426                        else field.default
427             if field.metadata and 'transform' in field.metadata:
428                 return field.metadata['transform'](v)
429             if not isinstance(v, field.type):  # type: ignore[arg-type]
430                 raise UsageError(f"Parameter '{field.name}' needs to be of {field.type!s}.")
431             return v
432
433         return cls(**{f.name: _check_field(kwargs[f.name], f)
434                       for f in dataclasses.fields(cls) if f.name in kwargs})
435
436
437 @dataclasses.dataclass
438 class ReverseDetails(LookupDetails):
439     """ Collection of parameters for the reverse call.
440     """
441
442     max_rank: int = dataclasses.field(default=30,
443                                       metadata={'transform': lambda v: max(0, min(v, 30))})
444     """ Highest address rank to return.
445     """
446
447     layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
448     """ Filter which kind of data to include.
449     """
450
451
452 @dataclasses.dataclass
453 class SearchDetails(LookupDetails):
454     """ Collection of parameters for the search call.
455     """
456     max_results: int = 10
457     """ Maximum number of results to be returned. The actual number of results
458         may be less.
459     """
460
461     min_rank: int = dataclasses.field(default=0,
462                                       metadata={'transform': lambda v: max(0, min(v, 30))})
463     """ Lowest address rank to return.
464     """
465
466     max_rank: int = dataclasses.field(default=30,
467                                       metadata={'transform': lambda v: max(0, min(v, 30))})
468     """ Highest address rank to return.
469     """
470
471     layers: Optional[DataLayer] = dataclasses.field(default=None,
472                                                     metadata={'transform': lambda r: r})
473     """ Filter which kind of data to include. When 'None' (the default) then
474         filtering by layers is disabled.
475     """
476
477     countries: List[str] = dataclasses.field(default_factory=list,
478                                              metadata={'transform': format_country})
479     """ Restrict search results to the given countries. An empty list (the
480         default) will disable this filter.
481     """
482
483     excluded: List[int] = dataclasses.field(default_factory=list,
484                                             metadata={'transform': format_excluded})
485     """ List of OSM objects to exclude from the results. Currently only
486         works when the internal place ID is given.
487         An empty list (the default) will disable this filter.
488     """
489
490     viewbox: Optional[Bbox] = dataclasses.field(default=None,
491                                                 metadata={'transform': Bbox.from_param})
492     """ Focus the search on a given map area.
493     """
494
495     bounded_viewbox: bool = False
496     """ Use 'viewbox' as a filter and restrict results to places within the
497         given area.
498     """
499
500     near: Optional[Point] = dataclasses.field(default=None,
501                                               metadata={'transform': Point.from_param})
502     """ Order results by distance to the given point.
503     """
504
505     near_radius: Optional[float] = dataclasses.field(default=None,
506                                                      metadata={'transform': lambda r: r})
507     """ Use near point as a filter and drop results outside the given
508         radius. Radius is given in degrees WSG84.
509     """
510
511     categories: List[Tuple[str, str]] = dataclasses.field(default_factory=list,
512                                                           metadata={'transform': format_categories})
513     """ Restrict search to places with one of the given class/type categories.
514         An empty list (the default) will disable this filter.
515     """
516
517     viewbox_x2: Optional[Bbox] = None
518
519     def __post_init__(self) -> None:
520         if self.viewbox is not None:
521             xext = (self.viewbox.maxlon - self.viewbox.minlon)/2
522             yext = (self.viewbox.maxlat - self.viewbox.minlat)/2
523             self.viewbox_x2 = Bbox(self.viewbox.minlon - xext, self.viewbox.minlat - yext,
524                                    self.viewbox.maxlon + xext, self.viewbox.maxlat + yext)
525
526     def restrict_min_max_rank(self, new_min: int, new_max: int) -> None:
527         """ Change the min_rank and max_rank fields to respect the
528             given boundaries.
529         """
530         assert new_min <= new_max
531         self.min_rank = max(self.min_rank, new_min)
532         self.max_rank = min(self.max_rank, new_max)
533
534     def is_impossible(self) -> bool:
535         """ Check if the parameter configuration is contradictionary and
536             cannot yield any results.
537         """
538         return (self.min_rank > self.max_rank
539                 or (self.bounded_viewbox
540                     and self.viewbox is not None and self.near is not None
541                     and self.viewbox.contains(self.near))
542                 or (self.layers is not None and not self.layers)
543                 or (self.max_rank <= 4 and
544                     self.layers is not None and not self.layers & DataLayer.ADDRESS))
545
546     def layer_enabled(self, layer: DataLayer) -> bool:
547         """ Check if the given layer has been chosen. Also returns
548             true when layer restriction has been disabled completely.
549         """
550         return self.layers is None or bool(self.layers & layer)