diff --git a/.pylintrc b/.pylintrc index da858deb..5159c51a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -15,4 +15,4 @@ ignored-classes=NominatimArgs,closing # typed Python is enabled. See also https://github.com/PyCQA/pylint/issues/5273 disable=too-few-public-methods,duplicate-code,too-many-ancestors,bad-option-value,no-self-use,not-context-manager,use-dict-literal,chained-comparison -good-names=i,x,y,m,t,fd,db,cc,x1,x2,y1,y2,pt +good-names=i,x,y,m,t,fd,db,cc,x1,x2,y1,y2,pt,k,v diff --git a/nominatim/api/__init__.py b/nominatim/api/__init__.py index cf58f27a..0a91e281 100644 --- a/nominatim/api/__init__.py +++ b/nominatim/api/__init__.py @@ -31,5 +31,6 @@ from .results import (SourceTable as SourceTable, WordInfo as WordInfo, WordInfos as WordInfos, DetailedResult as DetailedResult, - ReverseResult as ReverseResult) + ReverseResult as ReverseResult, + ReverseResults as ReverseResults) from .localization import (Locales as Locales) diff --git a/nominatim/api/results.py b/nominatim/api/results.py index 2999b9a7..0e3ddeda 100644 --- a/nominatim/api/results.py +++ b/nominatim/api/results.py @@ -11,7 +11,7 @@ Data classes are part of the public API while the functions are for internal use only. That's why they are implemented as free-standing functions instead of member functions. """ -from typing import Optional, Tuple, Dict, Sequence, TypeVar, Type +from typing import Optional, Tuple, Dict, Sequence, TypeVar, Type, List import enum import dataclasses import datetime as dt @@ -22,6 +22,7 @@ from nominatim.typing import SaSelect, SaRow from nominatim.api.types import Point, Bbox, LookupDetails from nominatim.api.connection import SearchConnection from nominatim.api.logging import log +from nominatim.api.localization import Locales # This file defines complex result data classes. # pylint: disable=too-many-instance-attributes @@ -52,8 +53,30 @@ class AddressLine: rank_address: int distance: float + local_name: Optional[str] = None + + +class AddressLines(List[AddressLine]): + """ Sequence of address lines order in descending order by their rank. + """ + + def localize(self, locales: Locales) -> List[str]: + """ Set the local name of address parts according to the chosen + locale. Return the list of local names without duplications. + + Only address parts that are marked as isaddress are localized + and returned. + """ + label_parts: List[str] = [] + + for line in self: + if line.isaddress and line.names: + line.local_name = locales.display_name(line.names) + if not label_parts or label_parts[-1] != line.local_name: + label_parts.append(line.local_name) + + return label_parts -AddressLines = Sequence[AddressLine] @dataclasses.dataclass @@ -144,6 +167,12 @@ class ReverseResult(BaseResult): bbox: Optional[Bbox] = None +class ReverseResults(List[ReverseResult]): + """ Sequence of reverse lookup results ordered by distance. + May be empty when no result was found. + """ + + def _filter_geometries(row: SaRow) -> Dict[str, str]: return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212 if k.startswith('geometry_')} @@ -333,7 +362,7 @@ async def complete_address_details(conn: SearchConnection, result: BaseResult) - sql = sa.select(sfn).order_by(sa.column('rank_address').desc(), sa.column('isaddress').desc()) - result.address_rows = [] + result.address_rows = AddressLines() for row in await conn.execute(sql): result.address_rows.append(_result_row_to_address_row(row)) @@ -357,7 +386,7 @@ def _placex_select_address_row(conn: SearchConnection, async def complete_linked_places(conn: SearchConnection, result: BaseResult) -> None: """ Retrieve information about places that link to the result. """ - result.linked_rows = [] + result.linked_rows = AddressLines() if result.source_table != SourceTable.PLACEX: return @@ -392,7 +421,7 @@ async def complete_parented_places(conn: SearchConnection, result: BaseResult) - """ Retrieve information about places that the result provides the address for. """ - result.parented_rows = [] + result.parented_rows = AddressLines() if result.source_table != SourceTable.PLACEX: return diff --git a/nominatim/api/v1/classtypes.py b/nominatim/api/v1/classtypes.py index 4e3667d3..b8ed8a9c 100644 --- a/nominatim/api/v1/classtypes.py +++ b/nominatim/api/v1/classtypes.py @@ -10,6 +10,52 @@ Hard-coded information about tag catagories. These tables have been copied verbatim from the old PHP code. For future version a more flexible formatting is required. """ +from typing import Tuple, Optional, Mapping + +def get_label_tag(category: Tuple[str, str], extratags: Optional[Mapping[str, str]], + rank: int, country: Optional[str]) -> str: + """ Create a label tag for the given place that can be used as an XML name. + """ + if rank < 26 and extratags and 'place'in extratags: + label = extratags['place'] + elif category == ('boundary', 'administrative'): + label = ADMIN_LABELS.get((country or '', int(rank/2)))\ + or ADMIN_LABELS.get(('', int(rank/2)))\ + or 'Administrative' + elif category[1] == 'postal_code': + label = 'postcode' + elif rank < 26: + label = category[1] if category[1] != 'yes' else category[0] + elif rank < 28: + label = 'road' + elif category[0] == 'place'\ + and category[1] in ('house_number', 'house_name', 'country_code'): + label = category[1] + else: + label = category[0] + + return label.lower().replace(' ', '_') + + +ADMIN_LABELS = { + ('', 1): 'Continent', + ('', 2): 'Country', + ('', 3): 'Region', + ('', 4): 'State', + ('', 5): 'State District', + ('', 6): 'County', + ('', 7): 'Municipality', + ('', 8): 'City', + ('', 9): 'City District', + ('', 10): 'Suburb', + ('', 11): 'Neighbourhood', + ('', 12): 'City Block', + ('no', 3): 'State', + ('no', 4): 'County', + ('se', 3): 'State', + ('se', 4): 'County' +} + ICONS = { ('boundary', 'administrative'): 'poi_boundary_administrative', diff --git a/nominatim/api/v1/constants.py b/nominatim/api/v1/constants.py new file mode 100644 index 00000000..68150a4b --- /dev/null +++ b/nominatim/api/v1/constants.py @@ -0,0 +1,43 @@ +# 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. +""" +Constants shared by all formats. +""" + +import nominatim.api as napi + +# pylint: disable=line-too-long +OSM_ATTRIBUTION = 'Data © OpenStreetMap contributors, ODbL 1.0. http://www.openstreetmap.org/copyright' + +OSM_TYPE_NAME = { + 'N': 'node', + 'W': 'way', + 'R': 'relation' +} + +NODE_EXTENT = [25, 25, 25, 25, + 7, + 2.6, 2.6, 2.0, 1.0, 1.0, + 0.7, 0.7, 0.7, + 0.16, 0.16, 0.16, 0.16, + 0.04, 0.04, + 0.02, 0.02, + 0.01, 0.01, 0.01, 0.01, 0.01, + 0.015, 0.015, 0.015, 0.015, + 0.005] + + +def bbox_from_result(result: napi.ReverseResult) -> napi.Bbox: + """ Compute a bounding box for the result. For ways and relations + a given boundingbox is used. For all other object, a box is computed + around the centroid according to dimensions dereived from the + search rank. + """ + if (result.osm_object and result.osm_object[0] == 'N') or result.bbox is None: + return napi.Bbox.from_point(result.centroid, NODE_EXTENT[result.rank_search]) + + return result.bbox diff --git a/nominatim/api/v1/format.py b/nominatim/api/v1/format.py index 64892d66..47d2af4d 100644 --- a/nominatim/api/v1/format.py +++ b/nominatim/api/v1/format.py @@ -13,6 +13,7 @@ import collections import nominatim.api as napi from nominatim.api.result_formatting import FormatDispatcher from nominatim.api.v1.classtypes import ICONS +from nominatim.api.v1 import format_json, format_xml from nominatim.utils.json_writer import JsonWriter dispatch = FormatDispatcher() @@ -93,7 +94,7 @@ def _add_parent_rows_grouped(writer: JsonWriter, rows: napi.AddressLines, @dispatch.format_func(napi.DetailedResult, 'json') -def _format_search_json(result: napi.DetailedResult, options: Mapping[str, Any]) -> str: +def _format_details_json(result: napi.DetailedResult, options: Mapping[str, Any]) -> str: locales = options.get('locales', napi.Locales()) geom = result.geometry.get('geojson') centroid = result.centroid.to_geojson() @@ -161,3 +162,36 @@ def _format_search_json(result: napi.DetailedResult, options: Mapping[str, Any]) out.end_object() return out() + + +@dispatch.format_func(napi.ReverseResults, 'xml') +def _format_reverse_xml(results: napi.ReverseResults, options: Mapping[str, Any]) -> str: + return format_xml.format_base_xml(results, + options, True, 'reversegeocode', + {'querystring': 'TODO'}) + + +@dispatch.format_func(napi.ReverseResults, 'geojson') +def _format_reverse_geojson(results: napi.ReverseResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_geojson(results, options, True) + + +@dispatch.format_func(napi.ReverseResults, 'geocodejson') +def _format_reverse_geocodejson(results: napi.ReverseResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_geocodejson(results, options, True) + + +@dispatch.format_func(napi.ReverseResults, 'json') +def _format_reverse_json(results: napi.ReverseResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_json(results, options, True, + class_label='class') + + +@dispatch.format_func(napi.ReverseResults, 'jsonv2') +def _format_reverse_jsonv2(results: napi.ReverseResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_json(results, options, True, + class_label='category') diff --git a/nominatim/api/v1/format_json.py b/nominatim/api/v1/format_json.py new file mode 100644 index 00000000..898e6213 --- /dev/null +++ b/nominatim/api/v1/format_json.py @@ -0,0 +1,283 @@ +# 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 functions for output of results in json formats. +""" +from typing import Mapping, Any, Optional, Tuple + +import nominatim.api as napi +from nominatim.api.v1.constants import OSM_ATTRIBUTION, OSM_TYPE_NAME, bbox_from_result +from nominatim.api.v1.classtypes import ICONS, get_label_tag +from nominatim.utils.json_writer import JsonWriter + +def _write_osm_id(out: JsonWriter, osm_object: Optional[Tuple[str, int]]) -> None: + if osm_object is not None: + out.keyval_not_none('osm_type', OSM_TYPE_NAME.get(osm_object[0], None))\ + .keyval('osm_id', osm_object[1]) + + +def _write_typed_address(out: JsonWriter, address: Optional[napi.AddressLines], + country_code: Optional[str]) -> None: + parts = {} + for line in (address or []): + if line.isaddress and line.local_name: + label = get_label_tag(line.category, line.extratags, + line.rank_address, country_code) + if label not in parts: + parts[label] = line.local_name + + for k, v in parts.items(): + out.keyval(k, v) + + if country_code: + out.keyval('country_code', country_code) + + +def _write_geocodejson_address(out: JsonWriter, + address: Optional[napi.AddressLines], + obj_place_id: Optional[int], + country_code: Optional[str]) -> None: + extra = {} + for line in (address or []): + if line.isaddress and line.local_name: + if line.category[1] in ('postcode', 'postal_code'): + out.keyval('postcode', line.local_name) + elif line.category[1] == 'house_number': + out.keyval('housenumber', line.local_name) + elif (obj_place_id is None or obj_place_id != line.place_id) \ + and line.rank_address >= 4 and line.rank_address < 28: + extra[GEOCODEJSON_RANKS[line.rank_address]] = line.local_name + + for k, v in extra.items(): + out.keyval(k, v) + + if country_code: + out.keyval('country_code', country_code) + + +def format_base_json(results: napi.ReverseResults, #pylint: disable=too-many-branches + options: Mapping[str, Any], simple: bool, + class_label: str) -> str: + """ Return the result list as a simple json string in custom Nominatim format. + """ + locales = options.get('locales', napi.Locales()) + + out = JsonWriter() + + if simple: + if not results: + return '{"error":"Unable to geocode"}' + else: + out.start_array() + + for result in results: + label_parts = result.address_rows.localize(locales) if result.address_rows else [] + + out.start_object()\ + .keyval_not_none('place_id', result.place_id)\ + .keyval('licence', OSM_ATTRIBUTION)\ + + _write_osm_id(out, result.osm_object) + + out.keyval('lat', result.centroid.lat)\ + .keyval('lon', result.centroid.lon)\ + .keyval(class_label, result.category[0])\ + .keyval('type', result.category[1])\ + .keyval('place_rank', result.rank_search)\ + .keyval('importance', result.calculated_importance())\ + .keyval('addresstype', get_label_tag(result.category, result.extratags, + result.rank_address, + result.country_code))\ + .keyval('name', locales.display_name(result.names))\ + .keyval('display_name', ', '.join(label_parts)) + + + if options.get('icon_base_url', None): + icon = ICONS.get(result.category) + if icon: + out.keyval('icon', f"{options['icon_base_url']}/{icon}.p.20.png") + + if options.get('addressdetails', False): + out.key('address').start_object() + _write_typed_address(out, result.address_rows, result.country_code) + out.end_object().next() + + if options.get('extratags', False): + out.keyval('extratags', result.extratags) + + if options.get('namedetails', False): + out.keyval('namedetails', result.names) + + bbox = bbox_from_result(result) + out.key('boundingbox').start_array()\ + .value(bbox.minlat).next()\ + .value(bbox.maxlat).next()\ + .value(bbox.minlon).next()\ + .value(bbox.maxlon).next()\ + .end_array().next() + + if result.geometry: + for key in ('text', 'kml'): + out.keyval_not_none('geo' + key, result.geometry.get(key)) + if 'geojson' in result.geometry: + out.key('geojson').raw(result.geometry['geojson']).next() + out.keyval_not_none('svg', result.geometry.get('svg')) + + out.end_object() + + if simple: + return out() + + out.next() + + out.end_array() + + return out() + + +def format_base_geojson(results: napi.ReverseResults, + options: Mapping[str, Any], + simple: bool) -> str: + """ Return the result list as a geojson string. + """ + if not results and simple: + return '{"error":"Unable to geocode"}' + + locales = options.get('locales', napi.Locales()) + + out = JsonWriter() + + out.start_object()\ + .keyval('type', 'FeatureCollection')\ + .keyval('licence', OSM_ATTRIBUTION)\ + .key('features').start_array() + + for result in results: + if result.address_rows: + label_parts = result.address_rows.localize(locales) + else: + label_parts = [] + + out.start_object()\ + .keyval('type', 'Feature')\ + .key('properties').start_object() + + out.keyval_not_none('place_id', result.place_id) + + _write_osm_id(out, result.osm_object) + + out.keyval('place_rank', result.rank_search)\ + .keyval('category', result.category[0])\ + .keyval('type', result.category[1])\ + .keyval('importance', result.calculated_importance())\ + .keyval('addresstype', get_label_tag(result.category, result.extratags, + result.rank_address, + result.country_code))\ + .keyval('name', locales.display_name(result.names))\ + .keyval('display_name', ', '.join(label_parts)) + + if options.get('addressdetails', False): + out.key('address').start_object() + _write_typed_address(out, result.address_rows, result.country_code) + out.end_object().next() + + if options.get('extratags', False): + out.keyval('extratags', result.extratags) + + if options.get('namedetails', False): + out.keyval('namedetails', result.names) + + out.end_object().next() # properties + + bbox = bbox_from_result(result) + out.keyval('bbox', bbox.coords) + + out.key('geometry').raw(result.geometry.get('geojson') + or result.centroid.to_geojson()).next() + + out.end_object().next() + + out.end_array().next().end_object() + + return out() + + +def format_base_geocodejson(results: napi.ReverseResults, + options: Mapping[str, Any], simple: bool) -> str: + """ Return the result list as a geocodejson string. + """ + if not results and simple: + return '{"error":"Unable to geocode"}' + + locales = options.get('locales', napi.Locales()) + + out = JsonWriter() + + out.start_object()\ + .keyval('type', 'FeatureCollection')\ + .key('geocoding').start_object()\ + .keyval('version', '0.1.0')\ + .keyval('attribution', OSM_ATTRIBUTION)\ + .keyval('licence', 'ODbL')\ + .keyval_not_none('query', options.get('query'))\ + .end_object().next()\ + .key('features').start_array() + + for result in results: + if result.address_rows: + label_parts = result.address_rows.localize(locales) + else: + label_parts = [] + + out.start_object()\ + .keyval('type', 'Feature')\ + .key('properties').start_object()\ + .key('geocoding').start_object() + + out.keyval_not_none('place_id', result.place_id) + + _write_osm_id(out, result.osm_object) + + out.keyval('osm_key', result.category[0])\ + .keyval('osm_value', result.category[1])\ + .keyval('type', GEOCODEJSON_RANKS[max(3, min(28, result.rank_address))])\ + .keyval_not_none('accuracy', result.distance)\ + .keyval('label', ', '.join(label_parts))\ + .keyval_not_none('name', locales.display_name(result.names))\ + + if options.get('addressdetails', False): + _write_geocodejson_address(out, result.address_rows, result.place_id, + result.country_code) + + out.key('admin').start_object() + if result.address_rows: + for line in result.address_rows: + if line.isaddress and (line.admin_level or 15) < 15 and line.local_name: + out.keyval(f"level{line.admin_level}", line.local_name) + out.end_object().next() + + out.end_object().next().end_object().next() + + out.key('geometry').raw(result.geometry.get('geojson') + or result.centroid.to_geojson()).next() + + out.end_object().next() + + out.end_array().next().end_object() + + return out() + + +GEOCODEJSON_RANKS = { + 3: 'locality', + 4: 'country', + 5: 'state', 6: 'state', 7: 'state', 8: 'state', 9: 'state', + 10: 'county', 11: 'county', 12: 'county', + 13: 'city', 14: 'city', 15: 'city', 16: 'city', + 17: 'district', 18: 'district', 19: 'district', 20: 'district', 21: 'district', + 22: 'locality', 23: 'locality', 24: 'locality', + 25: 'street', 26: 'street', 27: 'street', 28: 'house'} diff --git a/nominatim/api/v1/format_xml.py b/nominatim/api/v1/format_xml.py new file mode 100644 index 00000000..b1159f93 --- /dev/null +++ b/nominatim/api/v1/format_xml.py @@ -0,0 +1,126 @@ +# 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 functions for output of results in XML format. +""" +from typing import Mapping, Any, Optional +import datetime as dt +import xml.etree.ElementTree as ET + +import nominatim.api as napi +from nominatim.api.v1.constants import OSM_ATTRIBUTION, OSM_TYPE_NAME, bbox_from_result +from nominatim.api.v1.classtypes import ICONS, get_label_tag + +def _write_xml_address(root: ET.Element, address: napi.AddressLines, + country_code: Optional[str]) -> None: + parts = {} + for line in address: + if line.isaddress and line.local_name: + label = get_label_tag(line.category, line.extratags, + line.rank_address, country_code) + if label not in parts: + parts[label] = line.local_name + + for k,v in parts.items(): + ET.SubElement(root, k).text = v + + if country_code: + ET.SubElement(root, 'country_code').text = country_code + + +def _create_base_entry(result: napi.ReverseResult, #pylint: disable=too-many-branches + root: ET.Element, simple: bool, + locales: napi.Locales) -> ET.Element: + if result.address_rows: + label_parts = result.address_rows.localize(locales) + else: + label_parts = [] + + place = ET.SubElement(root, 'result' if simple else 'place') + if result.place_id is not None: + place.set('place_id', str(result.place_id)) + if result.osm_object: + osm_type = OSM_TYPE_NAME.get(result.osm_object[0], None) + if osm_type is not None: + place.set('osm_type', osm_type) + place.set('osm_id', str(result.osm_object[1])) + if result.names and 'ref' in result.names: + place.set('place_id', result.names['ref']) + place.set('lat', str(result.centroid.lat)) + place.set('lon', str(result.centroid.lon)) + + bbox = bbox_from_result(result) + place.set('boundingbox', ','.join(map(str, [bbox.minlat, bbox.maxlat, + bbox.minlon, bbox.maxlon]))) + + place.set('place_rank', str(result.rank_search)) + place.set('address_rank', str(result.rank_address)) + + if result.geometry: + for key in ('text', 'svg'): + if key in result.geometry: + place.set('geo' + key, result.geometry[key]) + if 'kml' in result.geometry: + ET.SubElement(root if simple else place, 'geokml')\ + .append(ET.fromstring(result.geometry['kml'])) + if 'geojson' in result.geometry: + place.set('geojson', result.geometry['geojson']) + + if simple: + place.text = ', '.join(label_parts) + else: + place.set('display_name', ', '.join(label_parts)) + place.set('class', result.category[0]) + place.set('type', result.category[1]) + place.set('importance', str(result.calculated_importance())) + + return place + + +def format_base_xml(results: napi.ReverseResults, + options: Mapping[str, Any], + simple: bool, xml_root_tag: str, + xml_extra_info: Mapping[str, str]) -> str: + """ Format the result into an XML response. With 'simple' exactly one + result will be output, otherwise a list. + """ + locales = options.get('locales', napi.Locales()) + + root = ET.Element(xml_root_tag) + root.set('timestamp', dt.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S +00:00')) + root.set('attribution', OSM_ATTRIBUTION) + for k, v in xml_extra_info.items(): + root.set(k, v) + + if simple and not results: + ET.SubElement(root, 'error').text = 'Unable to geocode' + + for result in results: + place = _create_base_entry(result, root, simple, locales) + + if not simple and options.get('icon_base_url', None): + icon = ICONS.get(result.category) + if icon: + place.set('icon', icon) + + if options.get('addressdetails', False) and result.address_rows: + _write_xml_address(ET.SubElement(root, 'addressparts') if simple else place, + result.address_rows, result.country_code) + + if options.get('extratags', False): + eroot = ET.SubElement(root if simple else place, 'extratags') + if result.extratags: + for k, v in result.extratags.items(): + ET.SubElement(eroot, 'tag', attrib={'key': k, 'value': v}) + + if options.get('namedetails', False): + eroot = ET.SubElement(root if simple else place, 'namedetails') + if result.names: + for k,v in result.names.items(): + ET.SubElement(eroot, 'name', attrib={'desc': k}).text = v + + return '\n' + ET.tostring(root, encoding='unicode') diff --git a/test/python/api/test_result_formatting_v1.py b/test/python/api/test_result_formatting_v1.py index 3c35e625..e0fcc025 100644 --- a/test/python/api/test_result_formatting_v1.py +++ b/test/python/api/test_result_formatting_v1.py @@ -6,6 +6,9 @@ # For a full list of authors see the git log. """ Tests for formatting results for the V1 API. + +These test only ensure that the Python code is correct. +For functional tests see BDD test suite. """ import datetime as dt import json @@ -165,6 +168,28 @@ def test_search_details_with_geometry(): assert js['isarea'] == False +def test_search_details_with_icon_available(): + search = napi.DetailedResult(napi.SourceTable.PLACEX, + ('amenity', 'restaurant'), + napi.Point(1.0, 2.0)) + + result = api_impl.format_result(search, 'json', {'icon_base_url': 'foo'}) + js = json.loads(result) + + assert js['icon'] == 'foo/food_restaurant.p.20.png' + + +def test_search_details_with_icon_not_available(): + search = napi.DetailedResult(napi.SourceTable.PLACEX, + ('amenity', 'tree'), + napi.Point(1.0, 2.0)) + + result = api_impl.format_result(search, 'json', {'icon_base_url': 'foo'}) + js = json.loads(result) + + assert 'icon' not in js + + def test_search_details_with_address_minimal(): search = napi.DetailedResult(napi.SourceTable.PLACEX, ('place', 'thing'), @@ -193,28 +218,32 @@ def test_search_details_with_address_minimal(): 'isaddress': False}] -def test_search_details_with_address_full(): +@pytest.mark.parametrize('field,outfield', [('address_rows', 'address'), + ('linked_rows', 'linked_places'), + ('parented_rows', 'hierarchy') + ]) +def test_search_details_with_further_infos(field, outfield): search = napi.DetailedResult(napi.SourceTable.PLACEX, ('place', 'thing'), - napi.Point(1.0, 2.0), - address_rows=[ - napi.AddressLine(place_id=3498, - osm_object=('R', 442), - category=('bnd', 'note'), - names={'name': 'Trespass'}, - extratags={'access': 'no', - 'place_type': 'spec'}, - admin_level=4, - fromarea=True, - isaddress=True, - rank_address=10, - distance=0.034) - ]) + napi.Point(1.0, 2.0)) + + setattr(search, field, [napi.AddressLine(place_id=3498, + osm_object=('R', 442), + category=('bnd', 'note'), + names={'name': 'Trespass'}, + extratags={'access': 'no', + 'place_type': 'spec'}, + admin_level=4, + fromarea=True, + isaddress=True, + rank_address=10, + distance=0.034) + ]) result = api_impl.format_result(search, 'json', {}) js = json.loads(result) - assert js['address'] == [{'localname': 'Trespass', + assert js[outfield] == [{'localname': 'Trespass', 'place_id': 3498, 'osm_id': 442, 'osm_type': 'R', @@ -225,3 +254,70 @@ def test_search_details_with_address_full(): 'rank_address': 10, 'distance': 0.034, 'isaddress': True}] + + +def test_search_details_grouped_hierarchy(): + search = napi.DetailedResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0), + parented_rows = + [napi.AddressLine(place_id=3498, + osm_object=('R', 442), + category=('bnd', 'note'), + names={'name': 'Trespass'}, + extratags={'access': 'no', + 'place_type': 'spec'}, + admin_level=4, + fromarea=True, + isaddress=True, + rank_address=10, + distance=0.034) + ]) + + result = api_impl.format_result(search, 'json', {'group_hierarchy': True}) + js = json.loads(result) + + assert js['hierarchy'] == {'note': [{'localname': 'Trespass', + 'place_id': 3498, + 'osm_id': 442, + 'osm_type': 'R', + 'place_type': 'spec', + 'class': 'bnd', + 'type': 'note', + 'admin_level': 4, + 'rank_address': 10, + 'distance': 0.034, + 'isaddress': True}]} + + +def test_search_details_keywords_name(): + search = napi.DetailedResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0), + name_keywords=[ + napi.WordInfo(23, 'foo', 'mefoo'), + napi.WordInfo(24, 'foo', 'bafoo')]) + + result = api_impl.format_result(search, 'json', {'keywords': True}) + js = json.loads(result) + + assert js['keywords'] == {'name': [{'id': 23, 'token': 'foo'}, + {'id': 24, 'token': 'foo'}], + 'address': []} + + +def test_search_details_keywords_address(): + search = napi.DetailedResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0), + address_keywords=[ + napi.WordInfo(23, 'foo', 'mefoo'), + napi.WordInfo(24, 'foo', 'bafoo')]) + + result = api_impl.format_result(search, 'json', {'keywords': True}) + js = json.loads(result) + + assert js['keywords'] == {'address': [{'id': 23, 'token': 'foo'}, + {'id': 24, 'token': 'foo'}], + 'name': []} + diff --git a/test/python/api/test_result_formatting_v1_reverse.py b/test/python/api/test_result_formatting_v1_reverse.py new file mode 100644 index 00000000..6e94cf10 --- /dev/null +++ b/test/python/api/test_result_formatting_v1_reverse.py @@ -0,0 +1,320 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# 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 formatting reverse results for the V1 API. + +These test only ensure that the Python code is correct. +For functional tests see BDD test suite. +""" +import json +import xml.etree.ElementTree as ET + +import pytest + +import nominatim.api.v1 as api_impl +import nominatim.api as napi + +FORMATS = ['json', 'jsonv2', 'geojson', 'geocodejson', 'xml'] + +@pytest.mark.parametrize('fmt', FORMATS) +def test_format_reverse_minimal(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('amenity', 'post_box'), + napi.Point(0.3, -8.9)) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, {}) + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.tag == 'reversegeocode' + else: + result = json.loads(raw) + assert isinstance(result, dict) + + +@pytest.mark.parametrize('fmt', FORMATS) +def test_format_reverse_no_result(fmt): + raw = api_impl.format_result(napi.ReverseResults(), fmt, {}) + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.find('error').text == 'Unable to geocode' + else: + assert json.loads(raw) == {'error': 'Unable to geocode'} + + +@pytest.mark.parametrize('fmt', FORMATS) +def test_format_reverse_with_osm_id(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('amenity', 'post_box'), + napi.Point(0.3, -8.9), + place_id=5564, + osm_object=('N', 23)) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, {}) + + if fmt == 'xml': + root = ET.fromstring(raw).find('result') + assert root.attrib['osm_type'] == 'node' + assert root.attrib['osm_id'] == '23' + else: + result = json.loads(raw) + if fmt == 'geocodejson': + props = result['features'][0]['properties']['geocoding'] + elif fmt == 'geojson': + props = result['features'][0]['properties'] + else: + props = result + assert props['osm_type'] == 'node' + assert props['osm_id'] == 23 + + +@pytest.mark.parametrize('fmt', FORMATS) +def test_format_reverse_with_address(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0), + country_code='fe', + address_rows=napi.AddressLines([ + napi.AddressLine(place_id=None, + osm_object=None, + category=('place', 'county'), + names={'name': 'Hello'}, + extratags=None, + admin_level=5, + fromarea=False, + isaddress=True, + rank_address=10, + distance=0.0), + napi.AddressLine(place_id=None, + osm_object=None, + category=('place', 'county'), + names={'name': 'ByeBye'}, + extratags=None, + admin_level=5, + fromarea=False, + isaddress=False, + rank_address=10, + distance=0.0) + ])) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'addressdetails': True}) + + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.find('addressparts').find('county').text == 'Hello' + else: + result = json.loads(raw) + assert isinstance(result, dict) + + if fmt == 'geocodejson': + props = result['features'][0]['properties']['geocoding'] + assert 'admin' in props + assert props['county'] == 'Hello' + else: + if fmt == 'geojson': + props = result['features'][0]['properties'] + else: + props = result + assert 'address' in props + + +def test_format_reverse_geocodejson_special_parts(): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('place', 'house'), + napi.Point(1.0, 2.0), + place_id=33, + country_code='fe', + address_rows=napi.AddressLines([ + napi.AddressLine(place_id=None, + osm_object=None, + category=('place', 'house_number'), + names={'ref': '1'}, + extratags=None, + admin_level=15, + fromarea=False, + isaddress=True, + rank_address=10, + distance=0.0), + napi.AddressLine(place_id=None, + osm_object=None, + category=('place', 'postcode'), + names={'ref': '99446'}, + extratags=None, + admin_level=11, + fromarea=False, + isaddress=True, + rank_address=10, + distance=0.0), + napi.AddressLine(place_id=33, + osm_object=None, + category=('place', 'county'), + names={'name': 'Hello'}, + extratags=None, + admin_level=5, + fromarea=False, + isaddress=True, + rank_address=10, + distance=0.0) + ])) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), 'geocodejson', + {'addressdetails': True}) + + props = json.loads(raw)['features'][0]['properties']['geocoding'] + assert props['housenumber'] == '1' + assert props['postcode'] == '99446' + assert 'county' not in props + + +@pytest.mark.parametrize('fmt', FORMATS) +def test_format_reverse_with_address_none(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0), + address_rows=napi.AddressLines()) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'addressdetails': True}) + + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.find('addressparts') is None + else: + result = json.loads(raw) + assert isinstance(result, dict) + + if fmt == 'geocodejson': + props = result['features'][0]['properties']['geocoding'] + print(props) + assert 'admin' in props + else: + if fmt == 'geojson': + props = result['features'][0]['properties'] + else: + props = result + assert 'address' in props + + +@pytest.mark.parametrize('fmt', ['json', 'jsonv2', 'geojson', 'xml']) +def test_format_reverse_with_extratags(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0), + extratags={'one': 'A', 'two':'B'}) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'extratags': True}) + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.find('extratags').find('tag').attrib['key'] == 'one' + else: + result = json.loads(raw) + if fmt == 'geojson': + extra = result['features'][0]['properties']['extratags'] + else: + extra = result['extratags'] + + assert extra == {'one': 'A', 'two':'B'} + + +@pytest.mark.parametrize('fmt', ['json', 'jsonv2', 'geojson', 'xml']) +def test_format_reverse_with_extratags_none(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0)) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'extratags': True}) + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.find('extratags') is not None + else: + result = json.loads(raw) + if fmt == 'geojson': + extra = result['features'][0]['properties']['extratags'] + else: + extra = result['extratags'] + + assert extra is None + + +@pytest.mark.parametrize('fmt', ['json', 'jsonv2', 'geojson', 'xml']) +def test_format_reverse_with_namedetails_with_name(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0), + names={'name': 'A', 'ref':'1'}) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'namedetails': True}) + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.find('namedetails').find('name').text == 'A' + else: + result = json.loads(raw) + if fmt == 'geojson': + extra = result['features'][0]['properties']['namedetails'] + else: + extra = result['namedetails'] + + assert extra == {'name': 'A', 'ref':'1'} + + +@pytest.mark.parametrize('fmt', ['json', 'jsonv2', 'geojson', 'xml']) +def test_format_reverse_with_namedetails_without_name(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0)) + + raw = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'namedetails': True}) + + if fmt == 'xml': + root = ET.fromstring(raw) + assert root.find('namedetails') is not None + else: + result = json.loads(raw) + if fmt == 'geojson': + extra = result['features'][0]['properties']['namedetails'] + else: + extra = result['namedetails'] + + assert extra is None + + +@pytest.mark.parametrize('fmt', ['json', 'jsonv2']) +def test_search_details_with_icon_available(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('amenity', 'restaurant'), + napi.Point(1.0, 2.0)) + + result = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'icon_base_url': 'foo'}) + + js = json.loads(result) + + assert js['icon'] == 'foo/food_restaurant.p.20.png' + + +@pytest.mark.parametrize('fmt', ['json', 'jsonv2']) +def test_search_details_with_icon_not_available(fmt): + reverse = napi.ReverseResult(napi.SourceTable.PLACEX, + ('amenity', 'tree'), + napi.Point(1.0, 2.0)) + + result = api_impl.format_result(napi.ReverseResults([reverse]), fmt, + {'icon_base_url': 'foo'}) + + assert 'icon' not in json.loads(result) +