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