add output formatters for ReverseResults

These formatters are written in a way that they can be reused for
search results later.
This commit is contained in:
Sarah Hoffmann 2023-03-24 21:45:47 +01:00
parent 878302a622
commit 35b52c4656
10 changed files with 1002 additions and 24 deletions

View File

@ -15,4 +15,4 @@ ignored-classes=NominatimArgs,closing
# typed Python is enabled. See also https://github.com/PyCQA/pylint/issues/5273 # 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 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

View File

@ -31,5 +31,6 @@ from .results import (SourceTable as SourceTable,
WordInfo as WordInfo, WordInfo as WordInfo,
WordInfos as WordInfos, WordInfos as WordInfos,
DetailedResult as DetailedResult, DetailedResult as DetailedResult,
ReverseResult as ReverseResult) ReverseResult as ReverseResult,
ReverseResults as ReverseResults)
from .localization import (Locales as Locales) from .localization import (Locales as Locales)

View 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 internal use only. That's why they are implemented as free-standing functions
instead of member 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 enum
import dataclasses import dataclasses
import datetime as dt 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.types import Point, Bbox, LookupDetails
from nominatim.api.connection import SearchConnection from nominatim.api.connection import SearchConnection
from nominatim.api.logging import log from nominatim.api.logging import log
from nominatim.api.localization import Locales
# This file defines complex result data classes. # This file defines complex result data classes.
# pylint: disable=too-many-instance-attributes # pylint: disable=too-many-instance-attributes
@ -52,8 +53,30 @@ class AddressLine:
rank_address: int rank_address: int
distance: float 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]: def _filter_geometries(row: SaRow) -> Dict[str, str]:
return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212 return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212
if k.startswith('geometry_')} 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(), sql = sa.select(sfn).order_by(sa.column('rank_address').desc(),
sa.column('isaddress').desc()) sa.column('isaddress').desc())
result.address_rows = [] result.address_rows = AddressLines()
for row in await conn.execute(sql): for row in await conn.execute(sql):
result.address_rows.append(_result_row_to_address_row(row)) 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: async def complete_linked_places(conn: SearchConnection, result: BaseResult) -> None:
""" Retrieve information about places that link to the result. """ Retrieve information about places that link to the result.
""" """
result.linked_rows = [] result.linked_rows = AddressLines()
if result.source_table != SourceTable.PLACEX: if result.source_table != SourceTable.PLACEX:
return return
@ -392,7 +421,7 @@ async def complete_parented_places(conn: SearchConnection, result: BaseResult) -
""" Retrieve information about places that the result provides the """ Retrieve information about places that the result provides the
address for. address for.
""" """
result.parented_rows = [] result.parented_rows = AddressLines()
if result.source_table != SourceTable.PLACEX: if result.source_table != SourceTable.PLACEX:
return return

View File

@ -10,6 +10,52 @@ Hard-coded information about tag catagories.
These tables have been copied verbatim from the old PHP code. For future These tables have been copied verbatim from the old PHP code. For future
version a more flexible formatting is required. 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 = { ICONS = {
('boundary', 'administrative'): 'poi_boundary_administrative', ('boundary', 'administrative'): 'poi_boundary_administrative',

View File

@ -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

View File

@ -13,6 +13,7 @@ import collections
import nominatim.api as napi import nominatim.api as napi
from nominatim.api.result_formatting import FormatDispatcher from nominatim.api.result_formatting import FormatDispatcher
from nominatim.api.v1.classtypes import ICONS from nominatim.api.v1.classtypes import ICONS
from nominatim.api.v1 import format_json, format_xml
from nominatim.utils.json_writer import JsonWriter from nominatim.utils.json_writer import JsonWriter
dispatch = FormatDispatcher() 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()) locales = options.get('locales', napi.Locales())
geom = result.geometry.get('geojson') geom = result.geometry.get('geojson')
centroid = result.centroid.to_geojson() centroid = result.centroid.to_geojson()
@ -161,3 +162,36 @@ def _format_search_json(result: napi.DetailedResult, options: Mapping[str, Any])
out.end_object() out.end_object()
return out() 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')

View File

@ -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'}

View File

@ -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')

View File

@ -6,6 +6,9 @@
# For a full list of authors see the git log. # For a full list of authors see the git log.
""" """
Tests for formatting results for the V1 API. 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 datetime as dt
import json 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(): def test_search_details_with_address_minimal():
search = napi.DetailedResult(napi.SourceTable.PLACEX, search = napi.DetailedResult(napi.SourceTable.PLACEX,
('place', 'thing'), ('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, search = napi.DetailedResult(napi.SourceTable.PLACEX,
('place', 'thing'), ('place', 'thing'),
napi.Point(1.0, 2.0), napi.Point(1.0, 2.0))
address_rows=[
napi.AddressLine(place_id=3498, setattr(search, field, [napi.AddressLine(place_id=3498,
osm_object=('R', 442), osm_object=('R', 442),
category=('bnd', 'note'), category=('bnd', 'note'),
names={'name': 'Trespass'}, names={'name': 'Trespass'},
extratags={'access': 'no', extratags={'access': 'no',
'place_type': 'spec'}, 'place_type': 'spec'},
admin_level=4, admin_level=4,
fromarea=True, fromarea=True,
isaddress=True, isaddress=True,
rank_address=10, rank_address=10,
distance=0.034) distance=0.034)
]) ])
result = api_impl.format_result(search, 'json', {}) result = api_impl.format_result(search, 'json', {})
js = json.loads(result) js = json.loads(result)
assert js['address'] == [{'localname': 'Trespass', assert js[outfield] == [{'localname': 'Trespass',
'place_id': 3498, 'place_id': 3498,
'osm_id': 442, 'osm_id': 442,
'osm_type': 'R', 'osm_type': 'R',
@ -225,3 +254,70 @@ def test_search_details_with_address_full():
'rank_address': 10, 'rank_address': 10,
'distance': 0.034, 'distance': 0.034,
'isaddress': True}] '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': []}

View File

@ -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)