]> git.openstreetmap.org Git - nominatim.git/commitdiff
add output formatters for ReverseResults
authorSarah Hoffmann <lonvia@denofr.de>
Fri, 24 Mar 2023 20:45:47 +0000 (21:45 +0100)
committerSarah Hoffmann <lonvia@denofr.de>
Sat, 25 Mar 2023 14:45:03 +0000 (15:45 +0100)
These formatters are written in a way that they can be reused for
search results later.

.pylintrc
nominatim/api/__init__.py
nominatim/api/results.py
nominatim/api/v1/classtypes.py
nominatim/api/v1/constants.py [new file with mode: 0644]
nominatim/api/v1/format.py
nominatim/api/v1/format_json.py [new file with mode: 0644]
nominatim/api/v1/format_xml.py [new file with mode: 0644]
test/python/api/test_result_formatting_v1.py
test/python/api/test_result_formatting_v1_reverse.py [new file with mode: 0644]

index da858deb1b63b18c011b19ff7587db85cb74cfce..5159c51aac5f73beb4c947b2775a7bdf512d5021 100644 (file)
--- 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
 
 #   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
index cf58f27a491f8fc4f017149324bfb5c4cd2d3bb6..0a91e28185b33a4c4bcfa51200e7a55fbec7540d 100644 (file)
@@ -31,5 +31,6 @@ from .results import (SourceTable as SourceTable,
                       WordInfo as WordInfo,
                       WordInfos as WordInfos,
                       DetailedResult as DetailedResult,
                       WordInfo as WordInfo,
                       WordInfos as WordInfos,
                       DetailedResult as DetailedResult,
-                      ReverseResult as ReverseResult)
+                      ReverseResult as ReverseResult,
+                      ReverseResults as ReverseResults)
 from .localization import (Locales as Locales)
 from .localization import (Locales as Locales)
index 2999b9a781c29fe2e18cdc3e46007396beb23bcc..0e3ddeda778bea988b74ea95f5ecd5fe41f687cc 100644 (file)
@@ -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.
 """
 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
 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.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
 
 # This file defines complex result data classes.
 # pylint: disable=too-many-instance-attributes
@@ -52,8 +53,30 @@ class AddressLine:
     rank_address: int
     distance: float
 
     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
 
 
 @dataclasses.dataclass
@@ -144,6 +167,12 @@ class ReverseResult(BaseResult):
     bbox: Optional[Bbox] = None
 
 
     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_')}
 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())
 
     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))
 
     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.
     """
 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
 
     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.
     """
     """ 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
 
     if result.source_table != SourceTable.PLACEX:
         return
 
index 4e3667d323b6f7a195cd3acfc51cae1a7f4f9d87..b8ed8a9cd4ce7c47a6f1a047a3dbbc798899d5b1 100644 (file)
@@ -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.
 """
 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',
 
 ICONS = {
     ('boundary', 'administrative'): 'poi_boundary_administrative',
diff --git a/nominatim/api/v1/constants.py b/nominatim/api/v1/constants.py
new file mode 100644 (file)
index 0000000..68150a4
--- /dev/null
@@ -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
index 64892d664810d90ff9c2827a446cb7bfad2f0460..47d2af4d49f2eaeeee87cae9ff1cef1ed604cc48 100644 (file)
@@ -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
 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()
 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')
 
 
 @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()
     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()
     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 (file)
index 0000000..898e621
--- /dev/null
@@ -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 (file)
index 0000000..b1159f9
--- /dev/null
@@ -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 '<?xml version="1.0" encoding="UTF-8" ?>\n' + ET.tostring(root, encoding='unicode')
index 3c35e62552f78ce6b78c16e6b48e7b013bfaaffc..e0fcc02578612d02ff8e547c73e7ddef890161ad 100644 (file)
@@ -6,6 +6,9 @@
 # For a full list of authors see the git log.
 """
 Tests for formatting results for the V1 API.
 # 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
 """
 import datetime as dt
 import json
@@ -165,6 +168,28 @@ def test_search_details_with_geometry():
     assert js['isarea'] == False
 
 
     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'),
 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}]
 
 
                               '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'),
     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)
 
 
     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',
                               '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}]
                               '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 (file)
index 0000000..6e94cf1
--- /dev/null
@@ -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)
+