switch reverse CLI command to Python implementation

This commit is contained in:
Sarah Hoffmann
2023-03-26 18:09:33 +02:00
parent 86b43dc605
commit 6c67a4b500
5 changed files with 152 additions and 32 deletions

View File

@@ -30,8 +30,15 @@ def _select_from_placex(t: SaFromClause, wkt: Optional[str] = None) -> SaSelect:
""" """
if wkt is None: if wkt is None:
distance = t.c.distance distance = t.c.distance
centroid = t.c.centroid
else: else:
distance = t.c.geometry.ST_Distance(wkt) distance = t.c.geometry.ST_Distance(wkt)
centroid = sa.case(
(t.c.geometry.ST_GeometryType().in_(('ST_LineString',
'ST_MultiLineString')),
t.c.geometry.ST_ClosestPoint(wkt)),
else_=t.c.centroid).label('centroid')
return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name, return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
t.c.class_, t.c.type, t.c.class_, t.c.type,
@@ -39,11 +46,7 @@ def _select_from_placex(t: SaFromClause, wkt: Optional[str] = None) -> SaSelect:
t.c.housenumber, t.c.postcode, t.c.country_code, t.c.housenumber, t.c.postcode, t.c.country_code,
t.c.importance, t.c.wikipedia, t.c.importance, t.c.wikipedia,
t.c.parent_place_id, t.c.rank_address, t.c.rank_search, t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
sa.case( centroid,
(t.c.geometry.ST_GeometryType().in_(('ST_LineString',
'ST_MultiLineString')),
t.c.geometry.ST_ClosestPoint(wkt)),
else_=t.c.centroid).label('centroid'),
distance.label('distance'), distance.label('distance'),
t.c.geometry.ST_Expand(0).label('bbox')) t.c.geometry.ST_Expand(0).label('bbox'))

View File

@@ -325,8 +325,7 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) ->
fmt_options = {'locales': locales, fmt_options = {'locales': locales,
'extratags': params.get_bool('extratags', False), 'extratags': params.get_bool('extratags', False),
'namedetails': params.get_bool('namedetails', False), 'namedetails': params.get_bool('namedetails', False),
'addressdetails': params.get_bool('addressdetails', True), 'addressdetails': params.get_bool('addressdetails', True)}
'single_result': True}
if fmt == 'xml': if fmt == 'xml':
fmt_options['xml_roottag'] = 'reversegeocode' fmt_options['xml_roottag'] = 'reversegeocode'
fmt_options['xml_extra_info'] = {'querystring': 'TODO'} fmt_options['xml_extra_info'] = {'querystring': 'TODO'}

View File

@@ -18,6 +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
# Do not repeat documentation of subcommand classes. # Do not repeat documentation of subcommand classes.
# pylint: disable=C0111 # pylint: disable=C0111
@@ -53,7 +54,8 @@ def _add_api_output_arguments(parser: argparse.ArgumentParser) -> None:
group.add_argument('--polygon-output', group.add_argument('--polygon-output',
choices=['geojson', 'kml', 'svg', 'text'], choices=['geojson', 'kml', 'svg', 'text'],
help='Output geometry of results as a GeoJSON, KML, SVG or WKT') help='Output geometry of results as a GeoJSON, KML, SVG or WKT')
group.add_argument('--polygon-threshold', type=float, metavar='TOLERANCE', group.add_argument('--polygon-threshold', type=float, default = 0.0,
metavar='TOLERANCE',
help=("Simplify output geometry." help=("Simplify output geometry."
"Parameter is difference tolerance in degrees.")) "Parameter is difference tolerance in degrees."))
@@ -150,26 +152,46 @@ class APIReverse:
help='Longitude of coordinate to look up (in WGS84)') help='Longitude of coordinate to look up (in WGS84)')
group.add_argument('--zoom', type=int, group.add_argument('--zoom', type=int,
help='Level of detail required for the address') help='Level of detail required for the address')
group.add_argument('--layer', metavar='LAYER',
choices=[n.name.lower() for n in napi.DataLayer if n.name],
action='append', required=False, dest='layers',
help='OSM id to lookup in format <NRW><id> (may be repeated)')
_add_api_output_arguments(parser) _add_api_output_arguments(parser)
def run(self, args: NominatimArgs) -> int: def run(self, args: NominatimArgs) -> int:
params = dict(lat=args.lat, lon=args.lon, format=args.format) api = napi.NominatimAPI(args.project_dir)
if args.zoom is not None:
params['zoom'] = args.zoom
for param, _ in EXTRADATA_PARAMS: details = napi.LookupDetails(address_details=True, # needed for display name
if getattr(args, param): geometry_output=args.get_geometry_output(),
params[param] = '1' geometry_simplification=args.polygon_threshold or 0.0)
if args.lang:
params['accept-language'] = args.lang result = api.reverse(napi.Point(args.lon, args.lat),
if args.polygon_output: REVERSE_MAX_RANKS[max(0, min(18, args.zoom or 18))],
params['polygon_' + args.polygon_output] = '1' args.get_layers(napi.DataLayer.ADDRESS | napi.DataLayer.POI),
if args.polygon_threshold: details)
params['polygon_threshold'] = args.polygon_threshold
if result:
output = api_output.format_result(
napi.ReverseResults([result]),
args.format,
{'locales': args.get_locales(api.config.DEFAULT_LANGUAGE),
'extratags': args.extratags,
'namedetails': args.namedetails,
'addressdetails': args.addressdetails})
if args.format != 'xml':
# reformat the result, so it is pretty-printed
json.dump(json.loads(output), sys.stdout, indent=4, ensure_ascii=False)
else:
sys.stdout.write(output)
sys.stdout.write('\n')
return 0
LOG.error("Unable to geocode.")
return 42
return _run_api('reverse', args, params)
class APILookup: class APILookup:
@@ -270,23 +292,16 @@ class APIDetails:
if args.polygon_geojson: if args.polygon_geojson:
details.geometry_output = napi.GeometryFormat.GEOJSON details.geometry_output = napi.GeometryFormat.GEOJSON
if args.lang:
locales = napi.Locales.from_accept_languages(args.lang)
elif api.config.DEFAULT_LANGUAGE:
locales = napi.Locales.from_accept_languages(api.config.DEFAULT_LANGUAGE)
else:
locales = napi.Locales()
result = api.lookup(place, details) result = api.lookup(place, details)
if result: if result:
output = api_output.format_result( output = api_output.format_result(
result, result,
'json', 'json',
{'locales': locales, {'locales': args.get_locales(api.config.DEFAULT_LANGUAGE),
'group_hierarchy': args.group_hierarchy}) 'group_hierarchy': args.group_hierarchy})
# reformat the result, so it is pretty-printed # reformat the result, so it is pretty-printed
json.dump(json.loads(output), sys.stdout, indent=4) json.dump(json.loads(output), sys.stdout, indent=4, ensure_ascii=False)
sys.stdout.write('\n') sys.stdout.write('\n')
return 0 return 0

View File

@@ -10,11 +10,13 @@ Provides custom functions over command-line arguments.
from typing import Optional, List, Dict, Any, Sequence, Tuple from typing import Optional, List, Dict, Any, Sequence, Tuple
import argparse import argparse
import logging import logging
from functools import reduce
from pathlib import Path from pathlib import Path
from nominatim.errors import UsageError from nominatim.errors import UsageError
from nominatim.config import Configuration from nominatim.config import Configuration
from nominatim.typing import Protocol from nominatim.typing import Protocol
import nominatim.api as napi
LOG = logging.getLogger() LOG = logging.getLogger()
@@ -162,6 +164,7 @@ class NominatimArgs:
lat: float lat: float
lon: float lon: float
zoom: Optional[int] zoom: Optional[int]
layers: Optional[Sequence[str]]
# Arguments to 'lookup' # Arguments to 'lookup'
ids: Sequence[str] ids: Sequence[str]
@@ -211,3 +214,45 @@ class NominatimArgs:
raise UsageError('Cannot access file.') raise UsageError('Cannot access file.')
return files return files
def get_geometry_output(self) -> napi.GeometryFormat:
""" Get the requested geometry output format in a API-compatible
format.
"""
if not self.polygon_output:
return napi.GeometryFormat.NONE
if self.polygon_output == 'geojson':
return napi.GeometryFormat.GEOJSON
if self.polygon_output == 'kml':
return napi.GeometryFormat.KML
if self.polygon_output == 'svg':
return napi.GeometryFormat.SVG
if self.polygon_output == 'text':
return napi.GeometryFormat.TEXT
try:
return napi.GeometryFormat[self.polygon_output.upper()]
except KeyError as exp:
raise UsageError(f"Unknown polygon output format '{self.polygon_output}'.") from exp
def get_locales(self, default: Optional[str]) -> napi.Locales:
""" Get the locales from the language parameter.
"""
if self.lang:
return napi.Locales.from_accept_languages(self.lang)
if default:
return napi.Locales.from_accept_languages(default)
return napi.Locales()
def get_layers(self, default: napi.DataLayer) -> Optional[napi.DataLayer]:
""" Get the list of selected layers as a DataLayer enum.
"""
if not self.layers:
return default
return reduce(napi.DataLayer.__or__,
(napi.DataLayer[s.upper()] for s in self.layers))

View File

@@ -24,7 +24,6 @@ def test_no_api_without_phpcgi(endpoint):
@pytest.mark.parametrize("params", [('search', '--query', 'new'), @pytest.mark.parametrize("params", [('search', '--query', 'new'),
('search', '--city', 'Berlin'), ('search', '--city', 'Berlin'),
('reverse', '--lat', '0', '--lon', '0', '--zoom', '13'),
('lookup', '--id', 'N1')]) ('lookup', '--id', 'N1')])
class TestCliApiCallPhp: class TestCliApiCallPhp:
@@ -98,6 +97,65 @@ class TestCliDetailsCall:
json.loads(capsys.readouterr().out) json.loads(capsys.readouterr().out)
class TestCliReverseCall:
@pytest.fixture(autouse=True)
def setup_reverse_mock(self, monkeypatch):
result = napi.ReverseResult(napi.SourceTable.PLACEX, ('place', 'thing'),
napi.Point(1.0, -3.0),
names={'name':'Name', 'name:fr': 'Nom'},
extratags={'extra':'Extra'})
monkeypatch.setattr(napi.NominatimAPI, 'reverse',
lambda *args: result)
def test_reverse_simple(self, cli_call, tmp_path, capsys):
result = cli_call('reverse', '--project-dir', str(tmp_path),
'--lat', '34', '--lon', '34')
assert result == 0
out = json.loads(capsys.readouterr().out)
assert out['name'] == 'Name'
assert 'address' not in out
assert 'extratags' not in out
assert 'namedetails' not in out
@pytest.mark.parametrize('param,field', [('--addressdetails', 'address'),
('--extratags', 'extratags'),
('--namedetails', 'namedetails')])
def test_reverse_extra_stuff(self, cli_call, tmp_path, capsys, param, field):
result = cli_call('reverse', '--project-dir', str(tmp_path),
'--lat', '34', '--lon', '34', param)
assert result == 0
out = json.loads(capsys.readouterr().out)
assert field in out
def test_reverse_format(self, cli_call, tmp_path, capsys):
result = cli_call('reverse', '--project-dir', str(tmp_path),
'--lat', '34', '--lon', '34', '--format', 'geojson')
assert result == 0
out = json.loads(capsys.readouterr().out)
assert out['type'] == 'FeatureCollection'
def test_reverse_language(self, cli_call, tmp_path, capsys):
result = cli_call('reverse', '--project-dir', str(tmp_path),
'--lat', '34', '--lon', '34', '--lang', 'fr')
assert result == 0
out = json.loads(capsys.readouterr().out)
assert out['name'] == 'Nom'
QUERY_PARAMS = { QUERY_PARAMS = {
'search': ('--query', 'somewhere'), 'search': ('--query', 'somewhere'),
'reverse': ('--lat', '20', '--lon', '30'), 'reverse': ('--lat', '20', '--lon', '30'),
@@ -105,7 +163,7 @@ QUERY_PARAMS = {
'details': ('--node', '324') 'details': ('--node', '324')
} }
@pytest.mark.parametrize("endpoint", (('search', 'reverse', 'lookup'))) @pytest.mark.parametrize("endpoint", (('search', 'lookup')))
class TestCliApiCommonParameters: class TestCliApiCommonParameters:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)