switch API parameters to keyword arguments

This switches the input parameters for API calls to a generic
keyword argument catch-all which is then loaded into a dataclass
where the parameters are checked and forwarded to internal
function.

The dataclass gives more flexibility with the parameters and makes
it easier to reuse common parameters for the different API calls.
This commit is contained in:
Sarah Hoffmann
2023-05-18 17:42:23 +02:00
parent 8f88613a6b
commit bef5cea48e
10 changed files with 170 additions and 152 deletions

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ from nominatim.typing import SaColumn, SaSelect, SaFromClause, SaLabel, SaRow
from nominatim.api.connection import SearchConnection
import nominatim.api.results as nres
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
# the equal sign.
@@ -87,23 +87,34 @@ class ReverseGeocoder:
coordinate.
"""
def __init__(self, conn: SearchConnection, max_rank: int, layer: DataLayer,
details: LookupDetails) -> None:
def __init__(self, conn: SearchConnection, params: ReverseDetails) -> None:
self.conn = conn
self.max_rank = max_rank
self.layer = layer
self.details = details
self.params = params
@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:
""" 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:
""" 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:
@@ -112,21 +123,21 @@ class ReverseGeocoder:
return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL)
def _add_geometry_columns(self, sql: SaSelect, col: SaColumn) -> SaSelect:
if not self.details.geometry_output:
if not self.has_geometries():
return sql
out = []
if self.details.geometry_simplification > 0.0:
col = col.ST_SimplifyPreserveTopology(self.details.geometry_simplification)
if self.params.geometry_simplification > 0.0:
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'))
if self.details.geometry_output & GeometryFormat.TEXT:
if self.params.geometry_output & GeometryFormat.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'))
if self.details.geometry_output & GeometryFormat.SVG:
if self.params.geometry_output & GeometryFormat.SVG:
out.append(col.ST_AsSVG().label('geometry_svg'))
return sql.add_columns(*out)
@@ -233,7 +244,7 @@ class ReverseGeocoder:
inner.c.postcode, inner.c.country_code,
inner.c.distance)
if self.details.geometry_output:
if self.has_geometries():
sub = sql.subquery()
sql = self._add_geometry_columns(sql, sub.c.centroid)
@@ -263,7 +274,7 @@ class ReverseGeocoder:
inner.c.postcode,
inner.c.distance)
if self.details.geometry_output:
if self.has_geometries():
sub = sql.subquery()
sql = self._add_geometry_columns(sql, sub.c.centroid)
@@ -514,9 +525,7 @@ class ReverseGeocoder:
""" Look up a single coordinate. Returns the place information,
if a place was found near the coordinates or None otherwise.
"""
log().function('reverse_lookup',
coord=coord, max_rank=self.max_rank,
layer=self.layer, details=self.details)
log().function('reverse_lookup', coord=coord, params=self.params)
wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326)
@@ -539,6 +548,6 @@ class ReverseGeocoder:
result.distance = row.distance
if hasattr(row, 'bbox'):
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

View File

@@ -7,11 +7,13 @@
"""
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 enum
from struct import unpack
from nominatim.errors import UsageError
@dataclasses.dataclass
class PlaceID:
""" Reference an object by Nominatim's internal ID.
@@ -164,10 +166,22 @@ class GeometryFormat(enum.Flag):
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
class LookupDetails:
""" 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
""" Add the full geometry of the place to the result. Multiple
@@ -194,12 +208,39 @@ class LookupDetails:
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):
""" Layer types that can be selected for reverse and forward search.
The function supports type checking and throws a UsageError
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

@@ -8,7 +8,7 @@
Generic part of the server implementation of the v1 API.
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
import abc
import math
@@ -226,31 +226,32 @@ class ASGIAdaptor(abc.ABC):
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.
"""
details = napi.LookupDetails(address_details=True,
geometry_simplification=
self.get_float('polygon_threshold', 0.0))
numgeoms = 0
output = napi.GeometryFormat.NONE
if self.get_bool('polygon_geojson', False):
details.geometry_output |= napi.GeometryFormat.GEOJSON
output |= napi.GeometryFormat.GEOJSON
numgeoms += 1
if fmt not in ('geojson', 'geocodejson'):
if self.get_bool('polygon_text', False):
details.geometry_output |= napi.GeometryFormat.TEXT
output |= napi.GeometryFormat.TEXT
numgeoms += 1
if self.get_bool('polygon_kml', False):
details.geometry_output |= napi.GeometryFormat.KML
output |= napi.GeometryFormat.KML
numgeoms += 1
if self.get_bool('polygon_svg', False):
details.geometry_output |= napi.GeometryFormat.SVG
output |= napi.GeometryFormat.SVG
numgeoms += 1
if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
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:
@@ -285,17 +286,17 @@ async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) ->
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())
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:
return params.build_response(loglib.get_and_disable())
@@ -318,15 +319,13 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) ->
debug = params.setup_debugging()
coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
details = params.parse_geometry_details(fmt)
zoom = max(0, min(18, params.get_int('zoom', 18)))
details = params.parse_geometry_details(fmt)
details['max_rank'] = REVERSE_MAX_RANKS[zoom]
details['layers'] = params.get_layers()
result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
params.get_layers() or
napi.DataLayer.ADDRESS | napi.DataLayer.POI,
details)
result = await api.reverse(coord, **details)
if debug:
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:])))
if places:
results = await api.lookup(places, details)
results = await api.lookup(places, **details)
else:
results = napi.SearchResults()