Merge pull request #3063 from lonvia/variable-parameters

Rework how search parameters are handed to the Python API
This commit is contained in:
Sarah Hoffmann
2023-05-18 22:27:18 +02:00
committed by GitHub
12 changed files with 246 additions and 178 deletions

View File

@@ -23,7 +23,6 @@ from .types import (PlaceID as PlaceID,
Point as Point, Point as Point,
Bbox as Bbox, Bbox as Bbox,
GeometryFormat as GeometryFormat, GeometryFormat as GeometryFormat,
LookupDetails as LookupDetails,
DataLayer as DataLayer) DataLayer as DataLayer)
from .results import (SourceTable as SourceTable, from .results import (SourceTable as SourceTable,
AddressLine as AddressLine, AddressLine as AddressLine,

View File

@@ -23,7 +23,7 @@ from nominatim.api.connection import SearchConnection
from nominatim.api.status import get_status, StatusResult from nominatim.api.status import get_status, StatusResult
from nominatim.api.lookup import get_detailed_place, get_simple_place from nominatim.api.lookup import get_detailed_place, get_simple_place
from nominatim.api.reverse import ReverseGeocoder from nominatim.api.reverse import ReverseGeocoder
from nominatim.api.types import PlaceRef, LookupDetails, AnyPoint, DataLayer import nominatim.api.types as ntyp
from nominatim.api.results import DetailedResult, ReverseResult, SearchResults from nominatim.api.results import DetailedResult, ReverseResult, SearchResults
@@ -128,32 +128,28 @@ class NominatimAPIAsync:
return status return status
async def details(self, place: PlaceRef, async def details(self, place: ntyp.PlaceRef, **params: Any) -> Optional[DetailedResult]:
details: Optional[LookupDetails] = None) -> Optional[DetailedResult]:
""" Get detailed information about a place in the database. """ Get detailed information about a place in the database.
Returns None if there is no entry under the given ID. Returns None if there is no entry under the given ID.
""" """
async with self.begin() as conn: async with self.begin() as conn:
return await get_detailed_place(conn, place, details or LookupDetails()) return await get_detailed_place(conn, place,
ntyp.LookupDetails.from_kwargs(params))
async def lookup(self, places: Sequence[PlaceRef], async def lookup(self, places: Sequence[ntyp.PlaceRef], **params: Any) -> SearchResults:
details: Optional[LookupDetails] = None) -> SearchResults:
""" Get simple information about a list of places. """ Get simple information about a list of places.
Returns a list of place information for all IDs that were found. Returns a list of place information for all IDs that were found.
""" """
if details is None: details = ntyp.LookupDetails.from_kwargs(params)
details = LookupDetails()
async with self.begin() as conn: async with self.begin() as conn:
return SearchResults(filter(None, return SearchResults(filter(None,
[await get_simple_place(conn, p, details) for p in places])) [await get_simple_place(conn, p, details) for p in places]))
async def reverse(self, coord: AnyPoint, max_rank: Optional[int] = None, async def reverse(self, coord: ntyp.AnyPoint, **params: Any) -> Optional[ReverseResult]:
layer: Optional[DataLayer] = None,
details: Optional[LookupDetails] = None) -> Optional[ReverseResult]:
""" Find a place by its coordinates. Also known as reverse geocoding. """ Find a place by its coordinates. Also known as reverse geocoding.
Returns the closest result that can be found or None if Returns the closest result that can be found or None if
@@ -164,14 +160,8 @@ class NominatimAPIAsync:
# There are no results to be expected outside valid coordinates. # There are no results to be expected outside valid coordinates.
return None return None
if layer is None:
layer = DataLayer.ADDRESS | DataLayer.POI
max_rank = max(0, min(max_rank or 30, 30))
async with self.begin() as conn: async with self.begin() as conn:
geocoder = ReverseGeocoder(conn, max_rank, layer, geocoder = ReverseGeocoder(conn, ntyp.ReverseDetails.from_kwargs(params))
details or LookupDetails())
return await geocoder.lookup(coord) return await geocoder.lookup(coord)
@@ -206,29 +196,24 @@ class NominatimAPI:
return self._loop.run_until_complete(self._async_api.status()) return self._loop.run_until_complete(self._async_api.status())
def details(self, place: PlaceRef, def details(self, place: ntyp.PlaceRef, **params: Any) -> Optional[DetailedResult]:
details: Optional[LookupDetails] = None) -> Optional[DetailedResult]:
""" Get detailed information about a place in the database. """ Get detailed information about a place in the database.
""" """
return self._loop.run_until_complete(self._async_api.details(place, details)) return self._loop.run_until_complete(self._async_api.details(place, **params))
def lookup(self, places: Sequence[PlaceRef], def lookup(self, places: Sequence[ntyp.PlaceRef], **params: Any) -> SearchResults:
details: Optional[LookupDetails] = None) -> SearchResults:
""" Get simple information about a list of places. """ Get simple information about a list of places.
Returns a list of place information for all IDs that were found. Returns a list of place information for all IDs that were found.
""" """
return self._loop.run_until_complete(self._async_api.lookup(places, details)) return self._loop.run_until_complete(self._async_api.lookup(places, **params))
def reverse(self, coord: AnyPoint, max_rank: Optional[int] = None, def reverse(self, coord: ntyp.AnyPoint, **params: Any) -> Optional[ReverseResult]:
layer: Optional[DataLayer] = None,
details: Optional[LookupDetails] = None) -> Optional[ReverseResult]:
""" Find a place by its coordinates. Also known as reverse geocoding. """ Find a place by its coordinates. Also known as reverse geocoding.
Returns the closest result that can be found or None if Returns the closest result that can be found or None if
no place matches the given criteria. no place matches the given criteria.
""" """
return self._loop.run_until_complete( return self._loop.run_until_complete(self._async_api.reverse(coord, **params))
self._async_api.reverse(coord, max_rank, layer, details))

View File

@@ -16,7 +16,7 @@ from nominatim.typing import SaColumn, SaSelect, SaFromClause, SaLabel, SaRow
from nominatim.api.connection import SearchConnection from nominatim.api.connection import SearchConnection
import nominatim.api.results as nres import nominatim.api.results as nres
from nominatim.api.logging import log from nominatim.api.logging import log
from nominatim.api.types import AnyPoint, DataLayer, LookupDetails, GeometryFormat, Bbox from nominatim.api.types import AnyPoint, DataLayer, ReverseDetails, GeometryFormat, Bbox
# In SQLAlchemy expression which compare with NULL need to be expressed with # In SQLAlchemy expression which compare with NULL need to be expressed with
# the equal sign. # the equal sign.
@@ -87,23 +87,34 @@ class ReverseGeocoder:
coordinate. coordinate.
""" """
def __init__(self, conn: SearchConnection, max_rank: int, layer: DataLayer, def __init__(self, conn: SearchConnection, params: ReverseDetails) -> None:
details: LookupDetails) -> None:
self.conn = conn self.conn = conn
self.max_rank = max_rank self.params = params
self.layer = layer
self.details = details
@property
def max_rank(self) -> int:
""" Return the maximum configured rank.
"""
return self.params.max_rank
def has_geometries(self) -> bool:
""" Check if any geometries are requested.
"""
return bool(self.params.geometry_output)
def layer_enabled(self, *layer: DataLayer) -> bool: def layer_enabled(self, *layer: DataLayer) -> bool:
""" Return true when any of the given layer types are requested. """ Return true when any of the given layer types are requested.
""" """
return any(self.layer & l for l in layer) return any(self.params.layers & l for l in layer)
def layer_disabled(self, *layer: DataLayer) -> bool: def layer_disabled(self, *layer: DataLayer) -> bool:
""" Return true when none of the given layer types is requested. """ Return true when none of the given layer types is requested.
""" """
return not any(self.layer & l for l in layer) return not any(self.params.layers & l for l in layer)
def has_feature_layers(self) -> bool: def has_feature_layers(self) -> bool:
@@ -112,21 +123,21 @@ class ReverseGeocoder:
return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL) return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL)
def _add_geometry_columns(self, sql: SaSelect, col: SaColumn) -> SaSelect: def _add_geometry_columns(self, sql: SaSelect, col: SaColumn) -> SaSelect:
if not self.details.geometry_output: if not self.has_geometries():
return sql return sql
out = [] out = []
if self.details.geometry_simplification > 0.0: if self.params.geometry_simplification > 0.0:
col = col.ST_SimplifyPreserveTopology(self.details.geometry_simplification) col = col.ST_SimplifyPreserveTopology(self.params.geometry_simplification)
if self.details.geometry_output & GeometryFormat.GEOJSON: if self.params.geometry_output & GeometryFormat.GEOJSON:
out.append(col.ST_AsGeoJSON().label('geometry_geojson')) out.append(col.ST_AsGeoJSON().label('geometry_geojson'))
if self.details.geometry_output & GeometryFormat.TEXT: if self.params.geometry_output & GeometryFormat.TEXT:
out.append(col.ST_AsText().label('geometry_text')) out.append(col.ST_AsText().label('geometry_text'))
if self.details.geometry_output & GeometryFormat.KML: if self.params.geometry_output & GeometryFormat.KML:
out.append(col.ST_AsKML().label('geometry_kml')) out.append(col.ST_AsKML().label('geometry_kml'))
if self.details.geometry_output & GeometryFormat.SVG: if self.params.geometry_output & GeometryFormat.SVG:
out.append(col.ST_AsSVG().label('geometry_svg')) out.append(col.ST_AsSVG().label('geometry_svg'))
return sql.add_columns(*out) return sql.add_columns(*out)
@@ -224,7 +235,7 @@ class ReverseGeocoder:
if parent_place_id is not None: if parent_place_id is not None:
sql = sql.where(t.c.parent_place_id == parent_place_id) sql = sql.where(t.c.parent_place_id == parent_place_id)
inner = sql.subquery() inner = sql.subquery('ipol')
sql = sa.select(inner.c.place_id, inner.c.osm_id, sql = sa.select(inner.c.place_id, inner.c.osm_id,
inner.c.parent_place_id, inner.c.address, inner.c.parent_place_id, inner.c.address,
@@ -233,9 +244,9 @@ class ReverseGeocoder:
inner.c.postcode, inner.c.country_code, inner.c.postcode, inner.c.country_code,
inner.c.distance) inner.c.distance)
if self.details.geometry_output: if self.has_geometries():
sub = sql.subquery() sub = sql.subquery('geom')
sql = self._add_geometry_columns(sql, sub.c.centroid) sql = self._add_geometry_columns(sa.select(sub), sub.c.centroid)
return (await self.conn.execute(sql)).one_or_none() return (await self.conn.execute(sql)).one_or_none()
@@ -252,7 +263,7 @@ class ReverseGeocoder:
.where(t.c.parent_place_id == parent_place_id)\ .where(t.c.parent_place_id == parent_place_id)\
.order_by('distance')\ .order_by('distance')\
.limit(1)\ .limit(1)\
.subquery() .subquery('tiger')
sql = sa.select(inner.c.place_id, sql = sa.select(inner.c.place_id,
inner.c.parent_place_id, inner.c.parent_place_id,
@@ -263,9 +274,9 @@ class ReverseGeocoder:
inner.c.postcode, inner.c.postcode,
inner.c.distance) inner.c.distance)
if self.details.geometry_output: if self.has_geometries():
sub = sql.subquery() sub = sql.subquery('geom')
sql = self._add_geometry_columns(sql, sub.c.centroid) sql = self._add_geometry_columns(sa.select(sub), sub.c.centroid)
return (await self.conn.execute(sql)).one_or_none() return (await self.conn.execute(sql)).one_or_none()
@@ -345,7 +356,7 @@ class ReverseGeocoder:
.where(t.c.type != 'postcode')\ .where(t.c.type != 'postcode')\
.order_by(sa.desc(t.c.rank_search))\ .order_by(sa.desc(t.c.rank_search))\
.limit(50)\ .limit(50)\
.subquery() .subquery('area')
sql = _select_from_placex(inner)\ sql = _select_from_placex(inner)\
.where(inner.c.geometry.ST_Contains(wkt))\ .where(inner.c.geometry.ST_Contains(wkt))\
@@ -374,7 +385,7 @@ class ReverseGeocoder:
.intersects(wkt))\ .intersects(wkt))\
.order_by(sa.desc(t.c.rank_search))\ .order_by(sa.desc(t.c.rank_search))\
.limit(50)\ .limit(50)\
.subquery() .subquery('places')
touter = self.conn.t.placex.alias('outer') touter = self.conn.t.placex.alias('outer')
sql = _select_from_placex(inner)\ sql = _select_from_placex(inner)\
@@ -514,9 +525,7 @@ class ReverseGeocoder:
""" Look up a single coordinate. Returns the place information, """ Look up a single coordinate. Returns the place information,
if a place was found near the coordinates or None otherwise. if a place was found near the coordinates or None otherwise.
""" """
log().function('reverse_lookup', log().function('reverse_lookup', coord=coord, params=self.params)
coord=coord, max_rank=self.max_rank,
layer=self.layer, details=self.details)
wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326) wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326)
@@ -539,6 +548,6 @@ class ReverseGeocoder:
result.distance = row.distance result.distance = row.distance
if hasattr(row, 'bbox'): if hasattr(row, 'bbox'):
result.bbox = Bbox.from_wkb(row.bbox.data) result.bbox = Bbox.from_wkb(row.bbox.data)
await nres.add_result_details(self.conn, result, self.details) await nres.add_result_details(self.conn, result, self.params)
return result return result

View File

@@ -7,11 +7,13 @@
""" """
Complex datatypes used by the Nominatim API. Complex datatypes used by the Nominatim API.
""" """
from typing import Optional, Union, Tuple, NamedTuple from typing import Optional, Union, Tuple, NamedTuple, TypeVar, Type, Dict, Any
import dataclasses import dataclasses
import enum import enum
from struct import unpack from struct import unpack
from nominatim.errors import UsageError
@dataclasses.dataclass @dataclasses.dataclass
class PlaceID: class PlaceID:
""" Reference an object by Nominatim's internal ID. """ Reference an object by Nominatim's internal ID.
@@ -164,10 +166,22 @@ class GeometryFormat(enum.Flag):
TEXT = enum.auto() TEXT = enum.auto()
class DataLayer(enum.Flag):
""" Layer types that can be selected for reverse and forward search.
"""
POI = enum.auto()
ADDRESS = enum.auto()
RAILWAY = enum.auto()
MANMADE = enum.auto()
NATURAL = enum.auto()
TParam = TypeVar('TParam', bound='LookupDetails') # pylint: disable=invalid-name
@dataclasses.dataclass @dataclasses.dataclass
class LookupDetails: class LookupDetails:
""" Collection of parameters that define the amount of details """ Collection of parameters that define the amount of details
returned with a search result. returned with a lookup or details result.
""" """
geometry_output: GeometryFormat = GeometryFormat.NONE geometry_output: GeometryFormat = GeometryFormat.NONE
""" Add the full geometry of the place to the result. Multiple """ Add the full geometry of the place to the result. Multiple
@@ -194,12 +208,39 @@ class LookupDetails:
more the geometry gets simplified. more the geometry gets simplified.
""" """
@classmethod
def from_kwargs(cls: Type[TParam], kwargs: Dict[str, Any]) -> TParam:
""" Load the data fields of the class from a dictionary.
Unknown entries in the dictionary are ignored, missing ones
get the default setting.
class DataLayer(enum.Flag): The function supports type checking and throws a UsageError
""" Layer types that can be selected for reverse and forward search. when the value does not fit.
"""
def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
if v is None:
return field.default_factory() \
if field.default_factory != dataclasses.MISSING \
else field.default
if field.metadata and 'transform' in field.metadata:
return field.metadata['transform'](v)
if not isinstance(v, field.type):
raise UsageError(f"Parameter '{field.name}' needs to be of {field.type!s}.")
return v
return cls(**{f.name: _check_field(kwargs[f.name], f)
for f in dataclasses.fields(cls) if f.name in kwargs})
@dataclasses.dataclass
class ReverseDetails(LookupDetails):
""" Collection of parameters for the reverse call.
"""
max_rank: int = dataclasses.field(default=30,
metadata={'transform': lambda v: max(0, min(v, 30))}
)
""" Highest address rank to return.
"""
layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
""" Filter which kind of data to include.
""" """
POI = enum.auto()
ADDRESS = enum.auto()
RAILWAY = enum.auto()
MANMADE = enum.auto()
NATURAL = enum.auto()

View File

@@ -0,0 +1,31 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2023 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Helper function for parsing parameters and and outputting data
specifically for the v1 version of the API.
"""
REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea
4, 4, # 3-4 Country
8, # 5 State
10, 10, # 6-7 Region
12, 12, # 8-9 County
16, 17, # 10-11 City
18, # 12 Town
19, # 13 Village/Suburb
22, # 14 Hamlet/Neighbourhood
25, # 15 Localities
26, # 16 Major Streets
27, # 17 Minor Streets
30 # 18 Building
]
def zoom_to_rank(zoom: int) -> int:
""" Convert a zoom parameter into a rank according to the v1 API spec.
"""
return REVERSE_MAX_RANKS[max(0, min(18, zoom))]

View File

@@ -8,7 +8,7 @@
Generic part of the server implementation of the v1 API. Generic part of the server implementation of the v1 API.
Combine with the scaffolding provided for the various Python ASGI frameworks. Combine with the scaffolding provided for the various Python ASGI frameworks.
""" """
from typing import Optional, Any, Type, Callable, NoReturn, cast from typing import Optional, Any, Type, Callable, NoReturn, Dict, cast
from functools import reduce from functools import reduce
import abc import abc
import math import math
@@ -17,6 +17,7 @@ from nominatim.config import Configuration
import nominatim.api as napi import nominatim.api as napi
import nominatim.api.logging as loglib import nominatim.api.logging as loglib
from nominatim.api.v1.format import dispatch as formatting from nominatim.api.v1.format import dispatch as formatting
from nominatim.api.v1 import helpers
CONTENT_TYPE = { CONTENT_TYPE = {
'text': 'text/plain; charset=utf-8', 'text': 'text/plain; charset=utf-8',
@@ -226,31 +227,32 @@ class ASGIAdaptor(abc.ABC):
return fmt return fmt
def parse_geometry_details(self, fmt: str) -> napi.LookupDetails: def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
""" Create details strucutre from the supplied geometry parameters. """ Create details strucutre from the supplied geometry parameters.
""" """
details = napi.LookupDetails(address_details=True,
geometry_simplification=
self.get_float('polygon_threshold', 0.0))
numgeoms = 0 numgeoms = 0
output = napi.GeometryFormat.NONE
if self.get_bool('polygon_geojson', False): if self.get_bool('polygon_geojson', False):
details.geometry_output |= napi.GeometryFormat.GEOJSON output |= napi.GeometryFormat.GEOJSON
numgeoms += 1 numgeoms += 1
if fmt not in ('geojson', 'geocodejson'): if fmt not in ('geojson', 'geocodejson'):
if self.get_bool('polygon_text', False): if self.get_bool('polygon_text', False):
details.geometry_output |= napi.GeometryFormat.TEXT output |= napi.GeometryFormat.TEXT
numgeoms += 1 numgeoms += 1
if self.get_bool('polygon_kml', False): if self.get_bool('polygon_kml', False):
details.geometry_output |= napi.GeometryFormat.KML output |= napi.GeometryFormat.KML
numgeoms += 1 numgeoms += 1
if self.get_bool('polygon_svg', False): if self.get_bool('polygon_svg', False):
details.geometry_output |= napi.GeometryFormat.SVG output |= napi.GeometryFormat.SVG
numgeoms += 1 numgeoms += 1
if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'): if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
self.raise_error('Too many polgyon output options selected.') self.raise_error('Too many polgyon output options selected.')
return details return {'address_details': True,
'geometry_simplification': self.get_float('polygon_threshold', 0.0),
'geometry_output': output
}
async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any: async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
@@ -285,17 +287,17 @@ async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) ->
debug = params.setup_debugging() debug = params.setup_debugging()
details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
linked_places=params.get_bool('linkedplaces', False),
parented_places=params.get_bool('hierarchy', False),
keywords=params.get_bool('keywords', False))
if params.get_bool('polygon_geojson', False):
details.geometry_output = napi.GeometryFormat.GEOJSON
locales = napi.Locales.from_accept_languages(params.get_accepted_languages()) locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
result = await api.details(place, details) result = await api.details(place,
address_details=params.get_bool('addressdetails', False),
linked_places=params.get_bool('linkedplaces', False),
parented_places=params.get_bool('hierarchy', False),
keywords=params.get_bool('keywords', False),
geometry_output = napi.GeometryFormat.GEOJSON
if params.get_bool('polygon_geojson', False)
else napi.GeometryFormat.NONE
)
if debug: if debug:
return params.build_response(loglib.get_and_disable()) return params.build_response(loglib.get_and_disable())
@@ -318,15 +320,12 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) ->
debug = params.setup_debugging() debug = params.setup_debugging()
coord = napi.Point(params.get_float('lon'), params.get_float('lat')) coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
locales = napi.Locales.from_accept_languages(params.get_accepted_languages()) locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
details = params.parse_geometry_details(fmt) details = params.parse_geometry_details(fmt)
details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
details['layers'] = params.get_layers()
zoom = max(0, min(18, params.get_int('zoom', 18))) result = await api.reverse(coord, **details)
result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
params.get_layers() or
napi.DataLayer.ADDRESS | napi.DataLayer.POI,
details)
if debug: if debug:
return params.build_response(loglib.get_and_disable()) return params.build_response(loglib.get_and_disable())
@@ -357,7 +356,7 @@ async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> A
places.append(napi.OsmID(oid[0], int(oid[1:]))) places.append(napi.OsmID(oid[0], int(oid[1:])))
if places: if places:
results = await api.lookup(places, details) results = await api.lookup(places, **details)
else: else:
results = napi.SearchResults() results = napi.SearchResults()
@@ -375,22 +374,6 @@ async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> A
EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any] EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea
4, 4, # 3-4 Country
8, # 5 State
10, 10, # 6-7 Region
12, 12, # 8-9 County
16, 17, # 10-11 City
18, # 12 Town
19, # 13 Village/Suburb
22, # 14 Hamlet/Neighbourhood
25, # 15 Localities
26, # 16 Major Streets
27, # 17 Minor Streets
30 # 18 Building
]
ROUTES = [ ROUTES = [
('status', status_endpoint), ('status', status_endpoint),
('details', details_endpoint), ('details', details_endpoint),

View File

@@ -18,7 +18,7 @@ from nominatim.errors import UsageError
from nominatim.clicmd.args import NominatimArgs from nominatim.clicmd.args import NominatimArgs
import nominatim.api as napi import nominatim.api as napi
import nominatim.api.v1 as api_output import nominatim.api.v1 as api_output
from nominatim.api.v1.server_glue import REVERSE_MAX_RANKS from nominatim.api.v1.helpers import zoom_to_rank
# Do not repeat documentation of subcommand classes. # Do not repeat documentation of subcommand classes.
# pylint: disable=C0111 # pylint: disable=C0111
@@ -163,14 +163,12 @@ class APIReverse:
def run(self, args: NominatimArgs) -> int: def run(self, args: NominatimArgs) -> int:
api = napi.NominatimAPI(args.project_dir) api = napi.NominatimAPI(args.project_dir)
details = napi.LookupDetails(address_details=True, # needed for display name
geometry_output=args.get_geometry_output(),
geometry_simplification=args.polygon_threshold or 0.0)
result = api.reverse(napi.Point(args.lon, args.lat), result = api.reverse(napi.Point(args.lon, args.lat),
REVERSE_MAX_RANKS[max(0, min(18, args.zoom or 18))], max_rank=zoom_to_rank(args.zoom or 18),
args.get_layers(napi.DataLayer.ADDRESS | napi.DataLayer.POI), layers=args.get_layers(napi.DataLayer.ADDRESS | napi.DataLayer.POI),
details) address_details=True, # needed for display name
geometry_output=args.get_geometry_output(),
geometry_simplification=args.polygon_threshold)
if result: if result:
output = api_output.format_result( output = api_output.format_result(
@@ -216,13 +214,12 @@ class APILookup:
def run(self, args: NominatimArgs) -> int: def run(self, args: NominatimArgs) -> int:
api = napi.NominatimAPI(args.project_dir) api = napi.NominatimAPI(args.project_dir)
details = napi.LookupDetails(address_details=True, # needed for display name
geometry_output=args.get_geometry_output(),
geometry_simplification=args.polygon_threshold or 0.0)
places = [napi.OsmID(o[0], int(o[1:])) for o in args.ids] places = [napi.OsmID(o[0], int(o[1:])) for o in args.ids]
results = api.lookup(places, details) results = api.lookup(places,
address_details=True, # needed for display name
geometry_output=args.get_geometry_output(),
geometry_simplification=args.polygon_threshold or 0.0)
output = api_output.format_result( output = api_output.format_result(
results, results,
@@ -297,14 +294,15 @@ class APIDetails:
api = napi.NominatimAPI(args.project_dir) api = napi.NominatimAPI(args.project_dir)
details = napi.LookupDetails(address_details=args.addressdetails, result = api.details(place,
linked_places=args.linkedplaces, address_details=args.addressdetails,
parented_places=args.hierarchy, linked_places=args.linkedplaces,
keywords=args.keywords) parented_places=args.hierarchy,
if args.polygon_geojson: keywords=args.keywords,
details.geometry_output = napi.GeometryFormat.GEOJSON geometry_output=napi.GeometryFormat.GEOJSON
if args.polygon_geojson
else napi.GeometryFormat.NONE)
result = api.details(place, details)
if result: if result:
output = api_output.format_result( output = api_output.format_result(

View File

@@ -31,7 +31,7 @@ def test_lookup_in_placex(apiobj, idobj):
indexed_date=import_date, indexed_date=import_date,
geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)') geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)')
result = apiobj.api.details(idobj, napi.LookupDetails()) result = apiobj.api.details(idobj)
assert result is not None assert result is not None
@@ -79,7 +79,7 @@ def test_lookup_in_placex_minimal_info(apiobj):
indexed_date=import_date, indexed_date=import_date,
geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)') geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)')
result = apiobj.api.details(napi.PlaceID(332), napi.LookupDetails()) result = apiobj.api.details(napi.PlaceID(332))
assert result is not None assert result is not None
@@ -121,8 +121,7 @@ def test_lookup_in_placex_with_geometry(apiobj):
apiobj.add_placex(place_id=332, apiobj.add_placex(place_id=332,
geometry='LINESTRING(23 34, 23.1 34)') geometry='LINESTRING(23 34, 23.1 34)')
result = apiobj.api.details(napi.PlaceID(332), result = apiobj.api.details(napi.PlaceID(332), geometry_output=napi.GeometryFormat.GEOJSON)
napi.LookupDetails(geometry_output=napi.GeometryFormat.GEOJSON))
assert result.geometry == {'geojson': '{"type":"LineString","coordinates":[[23,34],[23.1,34]]}'} assert result.geometry == {'geojson': '{"type":"LineString","coordinates":[[23,34],[23.1,34]]}'}
@@ -144,8 +143,7 @@ def test_lookup_placex_with_address_details(apiobj):
country_code='pl', country_code='pl',
rank_search=17, rank_address=16) rank_search=17, rank_address=16)
result = apiobj.api.details(napi.PlaceID(332), result = apiobj.api.details(napi.PlaceID(332), address_details=True)
napi.LookupDetails(address_details=True))
assert result.address_rows == [ assert result.address_rows == [
napi.AddressLine(place_id=332, osm_object=('W', 4), napi.AddressLine(place_id=332, osm_object=('W', 4),
@@ -177,8 +175,7 @@ def test_lookup_place_with_linked_places_none_existing(apiobj):
country_code='pl', linked_place_id=45, country_code='pl', linked_place_id=45,
rank_search=27, rank_address=26) rank_search=27, rank_address=26)
result = apiobj.api.details(napi.PlaceID(332), result = apiobj.api.details(napi.PlaceID(332), linked_places=True)
napi.LookupDetails(linked_places=True))
assert result.linked_rows == [] assert result.linked_rows == []
@@ -197,8 +194,7 @@ def test_lookup_place_with_linked_places_existing(apiobj):
country_code='pl', linked_place_id=332, country_code='pl', linked_place_id=332,
rank_search=27, rank_address=26) rank_search=27, rank_address=26)
result = apiobj.api.details(napi.PlaceID(332), result = apiobj.api.details(napi.PlaceID(332), linked_places=True)
napi.LookupDetails(linked_places=True))
assert result.linked_rows == [ assert result.linked_rows == [
napi.AddressLine(place_id=1001, osm_object=('W', 5), napi.AddressLine(place_id=1001, osm_object=('W', 5),
@@ -220,8 +216,7 @@ def test_lookup_place_with_parented_places_not_existing(apiobj):
country_code='pl', parent_place_id=45, country_code='pl', parent_place_id=45,
rank_search=27, rank_address=26) rank_search=27, rank_address=26)
result = apiobj.api.details(napi.PlaceID(332), result = apiobj.api.details(napi.PlaceID(332), parented_places=True)
napi.LookupDetails(parented_places=True))
assert result.parented_rows == [] assert result.parented_rows == []
@@ -240,8 +235,7 @@ def test_lookup_place_with_parented_places_existing(apiobj):
country_code='pl', parent_place_id=332, country_code='pl', parent_place_id=332,
rank_search=27, rank_address=26) rank_search=27, rank_address=26)
result = apiobj.api.details(napi.PlaceID(332), result = apiobj.api.details(napi.PlaceID(332), parented_places=True)
napi.LookupDetails(parented_places=True))
assert result.parented_rows == [ assert result.parented_rows == [
napi.AddressLine(place_id=1001, osm_object=('N', 5), napi.AddressLine(place_id=1001, osm_object=('N', 5),
@@ -263,7 +257,7 @@ def test_lookup_in_osmline(apiobj, idobj):
indexed_date=import_date, indexed_date=import_date,
geometry='LINESTRING(23 34, 23 35)') geometry='LINESTRING(23 34, 23 35)')
result = apiobj.api.details(idobj, napi.LookupDetails()) result = apiobj.api.details(idobj)
assert result is not None assert result is not None
@@ -310,13 +304,13 @@ def test_lookup_in_osmline_split_interpolation(apiobj):
startnumber=11, endnumber=20, step=1) startnumber=11, endnumber=20, step=1)
for i in range(1, 6): for i in range(1, 6):
result = apiobj.api.details(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) result = apiobj.api.details(napi.OsmID('W', 9, str(i)))
assert result.place_id == 1000 assert result.place_id == 1000
for i in range(7, 11): for i in range(7, 11):
result = apiobj.api.details(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) result = apiobj.api.details(napi.OsmID('W', 9, str(i)))
assert result.place_id == 1001 assert result.place_id == 1001
for i in range(12, 22): for i in range(12, 22):
result = apiobj.api.details(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) result = apiobj.api.details(napi.OsmID('W', 9, str(i)))
assert result.place_id == 1002 assert result.place_id == 1002
@@ -340,8 +334,7 @@ def test_lookup_osmline_with_address_details(apiobj):
country_code='pl', country_code='pl',
rank_search=17, rank_address=16) rank_search=17, rank_address=16)
result = apiobj.api.details(napi.PlaceID(9000), result = apiobj.api.details(napi.PlaceID(9000), address_details=True)
napi.LookupDetails(address_details=True))
assert result.address_rows == [ assert result.address_rows == [
napi.AddressLine(place_id=None, osm_object=None, napi.AddressLine(place_id=None, osm_object=None,
@@ -383,7 +376,7 @@ def test_lookup_in_tiger(apiobj):
osm_type='W', osm_id=6601223, osm_type='W', osm_id=6601223,
geometry='LINESTRING(23 34, 23 35)') geometry='LINESTRING(23 34, 23 35)')
result = apiobj.api.details(napi.PlaceID(4924), napi.LookupDetails()) result = apiobj.api.details(napi.PlaceID(4924))
assert result is not None assert result is not None
@@ -441,8 +434,7 @@ def test_lookup_tiger_with_address_details(apiobj):
country_code='us', country_code='us',
rank_search=17, rank_address=16) rank_search=17, rank_address=16)
result = apiobj.api.details(napi.PlaceID(9000), result = apiobj.api.details(napi.PlaceID(9000), address_details=True)
napi.LookupDetails(address_details=True))
assert result.address_rows == [ assert result.address_rows == [
napi.AddressLine(place_id=None, osm_object=None, napi.AddressLine(place_id=None, osm_object=None,
@@ -483,7 +475,7 @@ def test_lookup_in_postcode(apiobj):
indexed_date=import_date, indexed_date=import_date,
geometry='POINT(-9.45 5.6)') geometry='POINT(-9.45 5.6)')
result = apiobj.api.details(napi.PlaceID(554), napi.LookupDetails()) result = apiobj.api.details(napi.PlaceID(554))
assert result is not None assert result is not None
@@ -537,8 +529,7 @@ def test_lookup_postcode_with_address_details(apiobj):
country_code='gb', country_code='gb',
rank_search=17, rank_address=16) rank_search=17, rank_address=16)
result = apiobj.api.details(napi.PlaceID(9000), result = apiobj.api.details(napi.PlaceID(9000), address_details=True)
napi.LookupDetails(address_details=True))
assert result.address_rows == [ assert result.address_rows == [
napi.AddressLine(place_id=332, osm_object=('N', 3333), napi.AddressLine(place_id=332, osm_object=('N', 3333),
@@ -570,7 +561,7 @@ def test_lookup_missing_object(apiobj, objid):
apiobj.add_placex(place_id=1, osm_type='N', osm_id=55, apiobj.add_placex(place_id=1, osm_type='N', osm_id=55,
class_='place', type='suburb') class_='place', type='suburb')
assert apiobj.api.details(objid, napi.LookupDetails()) is None assert apiobj.api.details(objid) is None
@pytest.mark.parametrize('gtype', (napi.GeometryFormat.KML, @pytest.mark.parametrize('gtype', (napi.GeometryFormat.KML,
@@ -580,5 +571,4 @@ def test_lookup_unsupported_geometry(apiobj, gtype):
apiobj.add_placex(place_id=332) apiobj.add_placex(place_id=332)
with pytest.raises(ValueError): with pytest.raises(ValueError):
apiobj.api.details(napi.PlaceID(332), apiobj.api.details(napi.PlaceID(332), geometry_output=gtype)
napi.LookupDetails(geometry_output=gtype))

View File

@@ -95,7 +95,7 @@ def test_lookup_multiple_places(apiobj):
result = apiobj.api.lookup((napi.OsmID('W', 1), result = apiobj.api.lookup((napi.OsmID('W', 1),
napi.OsmID('W', 4), napi.OsmID('W', 4),
napi.OsmID('W', 9928)), napi.LookupDetails()) napi.OsmID('W', 9928)))
assert len(result) == 2 assert len(result) == 2

View File

@@ -84,7 +84,7 @@ def test_reverse_rank_30_layers(apiobj, y, layer, place_id):
rank_search=30, rank_search=30,
centroid=(1.3, 0.70005)) centroid=(1.3, 0.70005))
assert apiobj.api.reverse((1.3, y), layer=layer).place_id == place_id assert apiobj.api.reverse((1.3, y), layers=layer).place_id == place_id
def test_reverse_poi_layer_with_no_pois(apiobj): def test_reverse_poi_layer_with_no_pois(apiobj):
@@ -95,7 +95,7 @@ def test_reverse_poi_layer_with_no_pois(apiobj):
centroid=(1.3, 0.70001)) centroid=(1.3, 0.70001))
assert apiobj.api.reverse((1.3, 0.70001), max_rank=29, assert apiobj.api.reverse((1.3, 0.70001), max_rank=29,
layer=napi.DataLayer.POI) is None layers=napi.DataLayer.POI) is None
def test_reverse_housenumber_on_street(apiobj): def test_reverse_housenumber_on_street(apiobj):
@@ -245,7 +245,7 @@ def test_reverse_larger_area_layers(apiobj, layer, place_id):
rank_search=16, rank_search=16,
centroid=(1.3, 0.70005)) centroid=(1.3, 0.70005))
assert apiobj.api.reverse((1.3, 0.7), layer=layer).place_id == place_id assert apiobj.api.reverse((1.3, 0.7), layers=layer).place_id == place_id
def test_reverse_country_lookup_no_objects(apiobj): def test_reverse_country_lookup_no_objects(apiobj):
@@ -296,10 +296,8 @@ def test_reverse_geometry_output_placex(apiobj, gtype):
country_code='xx', country_code='xx',
centroid=(0.5, 0.5)) centroid=(0.5, 0.5))
details = napi.LookupDetails(geometry_output=gtype) assert apiobj.api.reverse((59.3, 80.70001), geometry_output=gtype).place_id == 1001
assert apiobj.api.reverse((0.5, 0.5), geometry_output=gtype).place_id == 1003
assert apiobj.api.reverse((59.3, 80.70001), details=details).place_id == 1001
assert apiobj.api.reverse((0.5, 0.5), details=details).place_id == 1003
def test_reverse_simplified_geometry(apiobj): def test_reverse_simplified_geometry(apiobj):
@@ -309,9 +307,9 @@ def test_reverse_simplified_geometry(apiobj):
rank_search=30, rank_search=30,
centroid=(59.3, 80.70001)) centroid=(59.3, 80.70001))
details = napi.LookupDetails(geometry_output=napi.GeometryFormat.GEOJSON, details = dict(geometry_output=napi.GeometryFormat.GEOJSON,
geometry_simplification=0.1) geometry_simplification=0.1)
assert apiobj.api.reverse((59.3, 80.70001), details=details).place_id == 1001 assert apiobj.api.reverse((59.3, 80.70001), **details).place_id == 1001
def test_reverse_interpolation_geometry(apiobj): def test_reverse_interpolation_geometry(apiobj):
@@ -321,8 +319,7 @@ def test_reverse_interpolation_geometry(apiobj):
centroid=(10.0, 10.00001), centroid=(10.0, 10.00001),
geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)') geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)')
details = napi.LookupDetails(geometry_output=napi.GeometryFormat.TEXT) assert apiobj.api.reverse((10.0, 10.0), geometry_output=napi.GeometryFormat.TEXT)\
assert apiobj.api.reverse((10.0, 10.0), details=details)\
.geometry['text'] == 'POINT(10 10.00001)' .geometry['text'] == 'POINT(10 10.00001)'
@@ -339,8 +336,8 @@ def test_reverse_tiger_geometry(apiobj):
centroid=(10.0, 10.00001), centroid=(10.0, 10.00001),
geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)') geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)')
details = napi.LookupDetails(geometry_output=napi.GeometryFormat.GEOJSON) output = apiobj.api.reverse((10.0, 10.0),
output = apiobj.api.reverse((10.0, 10.0), details=details).geometry['geojson'] geometry_output=napi.GeometryFormat.GEOJSON).geometry['geojson']
assert json.loads(output) == {'coordinates': [10, 10.00001], 'type': 'Point'} assert json.loads(output) == {'coordinates': [10, 10.00001], 'type': 'Point'}

View File

@@ -0,0 +1,35 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2023 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Tests for loading of parameter dataclasses.
"""
import pytest
from nominatim.errors import UsageError
import nominatim.api.types as typ
def test_no_params_defaults():
params = typ.LookupDetails.from_kwargs({})
assert not params.parented_places
assert params.geometry_simplification == 0.0
@pytest.mark.parametrize('k,v', [('geometry_output', 'a'),
('linked_places', 0),
('geometry_simplification', 'NaN')])
def test_bad_format_reverse(k, v):
with pytest.raises(UsageError):
params = typ.ReverseDetails.from_kwargs({k: v})
@pytest.mark.parametrize('rin,rout', [(-23, 0), (0, 0), (1, 1),
(15, 15), (30, 30), (31, 30)])
def test_rank_params(rin, rout):
params = typ.ReverseDetails.from_kwargs({'max_rank': rin})
assert params.max_rank == rout

View File

@@ -81,7 +81,7 @@ class TestCliDetailsCall:
napi.Point(1.0, -3.0)) napi.Point(1.0, -3.0))
monkeypatch.setattr(napi.NominatimAPI, 'details', monkeypatch.setattr(napi.NominatimAPI, 'details',
lambda *args: result) lambda *args, **kwargs: result)
@pytest.mark.parametrize("params", [('--node', '1'), @pytest.mark.parametrize("params", [('--node', '1'),
('--way', '1'), ('--way', '1'),
@@ -106,7 +106,7 @@ class TestCliReverseCall:
extratags={'extra':'Extra'}) extratags={'extra':'Extra'})
monkeypatch.setattr(napi.NominatimAPI, 'reverse', monkeypatch.setattr(napi.NominatimAPI, 'reverse',
lambda *args: result) lambda *args, **kwargs: result)
def test_reverse_simple(self, cli_call, tmp_path, capsys): def test_reverse_simple(self, cli_call, tmp_path, capsys):
@@ -165,7 +165,7 @@ class TestCliLookupCall:
extratags={'extra':'Extra'}) extratags={'extra':'Extra'})
monkeypatch.setattr(napi.NominatimAPI, 'lookup', monkeypatch.setattr(napi.NominatimAPI, 'lookup',
lambda *args: napi.SearchResults([result])) lambda *args, **kwargs: napi.SearchResults([result]))
def test_lookup_simple(self, cli_call, tmp_path, capsys): def test_lookup_simple(self, cli_call, tmp_path, capsys):
result = cli_call('lookup', '--project-dir', str(tmp_path), result = cli_call('lookup', '--project-dir', str(tmp_path),