]> git.openstreetmap.org Git - nominatim.git/commitdiff
switch details cli command to new Python implementation
authorSarah Hoffmann <lonvia@denofr.de>
Fri, 3 Feb 2023 09:43:54 +0000 (10:43 +0100)
committerSarah Hoffmann <lonvia@denofr.de>
Sat, 4 Feb 2023 20:22:22 +0000 (21:22 +0100)
nominatim/api/__init__.py
nominatim/api/localization.py [new file with mode: 0644]
nominatim/api/result_formatting.py
nominatim/api/v1/classtypes.py [new file with mode: 0644]
nominatim/api/v1/format.py
nominatim/api/v1/server_glue.py
nominatim/clicmd/api.py
nominatim/clicmd/args.py
test/python/api/test_localization.py [new file with mode: 0644]
test/python/api/test_result_formatting_v1.py
test/python/cli/test_cmd_api.py

index debd91197ce1e3c253da0ea87639658118ea2bd1..ef1ebe3279398b070f550bac7f4d397202c2e6df 100644 (file)
@@ -28,3 +28,4 @@ from .results import (SourceTable as SourceTable,
                       WordInfo as WordInfo,
                       WordInfos as WordInfos,
                       SearchResult as SearchResult)
+from .localization import (Locales as Locales)
diff --git a/nominatim/api/localization.py b/nominatim/api/localization.py
new file mode 100644 (file)
index 0000000..09fe27c
--- /dev/null
@@ -0,0 +1,97 @@
+# 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 localizing names of results.
+"""
+from typing import Mapping, List, Optional
+
+import re
+
+class Locales:
+    """ Helper class for localization of names.
+
+        It takes a list of language prefixes in their order of preferred
+        usage.
+    """
+
+    def __init__(self, langs: Optional[List[str]] = None):
+        self.languages = langs or []
+        self.name_tags: List[str] = []
+
+        # Build the list of supported tags. It is currently hard-coded.
+        self._add_lang_tags('name')
+        self._add_tags('name', 'brand')
+        self._add_lang_tags('official_name', 'short_name')
+        self._add_tags('official_name', 'short_name', 'ref')
+
+
+    def __bool__(self) -> bool:
+        return len(self.languages) > 0
+
+
+    def _add_tags(self, *tags: str) -> None:
+        for tag in tags:
+            self.name_tags.append(tag)
+            self.name_tags.append(f"_place_{tag}")
+
+
+    def _add_lang_tags(self, *tags: str) -> None:
+        for tag in tags:
+            for lang in self.languages:
+                self.name_tags.append(f"{tag}:{lang}")
+                self.name_tags.append(f"_place_{tag}:{lang}")
+
+
+    def display_name(self, names: Optional[Mapping[str, str]]) -> str:
+        """ Return the best matching name from a dictionary of names
+            containing different name variants.
+
+            If 'names' is null or empty, an empty string is returned. If no
+            appropriate localization is found, the first name is returned.
+        """
+        if not names:
+            return ''
+
+        if len(names) > 1:
+            for tag in self.name_tags:
+                if tag in names:
+                    return names[tag]
+
+        # Nothing? Return any of the other names as a default.
+        return next(iter(names.values()))
+
+
+    @staticmethod
+    def from_accept_languages(langstr: str) -> 'Locales':
+        """ Create a localization object from a language list in the
+            format of HTTP accept-languages header.
+
+            The functions tries to be forgiving of format errors by first splitting
+            the string into comma-separated parts and then parsing each
+            description separately. Badly formatted parts are then ignored.
+        """
+        # split string into languages
+        candidates = []
+        for desc in langstr.split(','):
+            m = re.fullmatch(r'\s*([a-z_-]+)(?:;\s*q\s*=\s*([01](?:\.\d+)?))?\s*',
+                             desc, flags=re.I)
+            if m:
+                candidates.append((m[1], float(m[2] or 1.0)))
+
+        # sort the results by the weight of each language (preserving order).
+        candidates.sort(reverse=True, key=lambda e: e[1])
+
+        # If a language has a region variant, also add the language without
+        # variant but only if it isn't already in the list to not mess up the weight.
+        languages = []
+        for lid, _ in candidates:
+            languages.append(lid)
+            parts = lid.split('-', 1)
+            if len(parts) > 1 and all(c[0] != parts[0] for c in candidates):
+                languages.append(parts[0])
+
+        return Locales(languages)
index 09cf7db802959d0a8ebb76b10fb614459fef737c..a6bfa91c64fb5befb8fdb4dcb4b8acd0cbbb7eb3 100644 (file)
@@ -7,11 +7,11 @@
 """
 Helper classes and functions for formating results into API responses.
 """
-from typing import Type, TypeVar, Dict, List, Callable, Any
+from typing import Type, TypeVar, Dict, List, Callable, Any, Mapping
 from collections import defaultdict
 
 T = TypeVar('T') # pylint: disable=invalid-name
-FormatFunc = Callable[[T], str]
+FormatFunc = Callable[[T, Mapping[str, Any]], str]
 
 
 class FormatDispatcher:
@@ -47,10 +47,10 @@ class FormatDispatcher:
         return fmt in self.format_functions[result_type]
 
 
-    def format_result(self, result: Any, fmt: str) -> str:
+    def format_result(self, result: Any, fmt: str, options: Mapping[str, Any]) -> str:
         """ Convert the given result into a string using the given format.
 
             The format is expected to be in the list returned by
             `list_formats()`.
         """
-        return self.format_functions[type(result)][fmt](result)
+        return self.format_functions[type(result)][fmt](result, options)
diff --git a/nominatim/api/v1/classtypes.py b/nominatim/api/v1/classtypes.py
new file mode 100644 (file)
index 0000000..4e3667d
--- /dev/null
@@ -0,0 +1,98 @@
+# 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.
+"""
+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.
+"""
+
+ICONS = {
+    ('boundary', 'administrative'): 'poi_boundary_administrative',
+    ('place', 'city'): 'poi_place_city',
+    ('place', 'town'): 'poi_place_town',
+    ('place', 'village'): 'poi_place_village',
+    ('place', 'hamlet'): 'poi_place_village',
+    ('place', 'suburb'): 'poi_place_village',
+    ('place', 'locality'): 'poi_place_village',
+    ('place', 'airport'): 'transport_airport2',
+    ('aeroway', 'aerodrome'): 'transport_airport2',
+    ('railway', 'station'): 'transport_train_station2',
+    ('amenity', 'place_of_worship'): 'place_of_worship_unknown3',
+    ('amenity', 'pub'): 'food_pub',
+    ('amenity', 'bar'): 'food_bar',
+    ('amenity', 'university'): 'education_university',
+    ('tourism', 'museum'): 'tourist_museum',
+    ('amenity', 'arts_centre'): 'tourist_art_gallery2',
+    ('tourism', 'zoo'): 'tourist_zoo',
+    ('tourism', 'theme_park'): 'poi_point_of_interest',
+    ('tourism', 'attraction'): 'poi_point_of_interest',
+    ('leisure', 'golf_course'): 'sport_golf',
+    ('historic', 'castle'): 'tourist_castle',
+    ('amenity', 'hospital'): 'health_hospital',
+    ('amenity', 'school'): 'education_school',
+    ('amenity', 'theatre'): 'tourist_theatre',
+    ('amenity', 'library'): 'amenity_library',
+    ('amenity', 'fire_station'): 'amenity_firestation3',
+    ('amenity', 'police'): 'amenity_police2',
+    ('amenity', 'bank'): 'money_bank2',
+    ('amenity', 'post_office'): 'amenity_post_office',
+    ('tourism', 'hotel'): 'accommodation_hotel2',
+    ('amenity', 'cinema'): 'tourist_cinema',
+    ('tourism', 'artwork'): 'tourist_art_gallery2',
+    ('historic', 'archaeological_site'): 'tourist_archaeological2',
+    ('amenity', 'doctors'): 'health_doctors',
+    ('leisure', 'sports_centre'): 'sport_leisure_centre',
+    ('leisure', 'swimming_pool'): 'sport_swimming_outdoor',
+    ('shop', 'supermarket'): 'shopping_supermarket',
+    ('shop', 'convenience'): 'shopping_convenience',
+    ('amenity', 'restaurant'): 'food_restaurant',
+    ('amenity', 'fast_food'): 'food_fastfood',
+    ('amenity', 'cafe'): 'food_cafe',
+    ('tourism', 'guest_house'): 'accommodation_bed_and_breakfast',
+    ('amenity', 'pharmacy'): 'health_pharmacy_dispensing',
+    ('amenity', 'fuel'): 'transport_fuel',
+    ('natural', 'peak'): 'poi_peak',
+    ('natural', 'wood'): 'landuse_coniferous_and_deciduous',
+    ('shop', 'bicycle'): 'shopping_bicycle',
+    ('shop', 'clothes'): 'shopping_clothes',
+    ('shop', 'hairdresser'): 'shopping_hairdresser',
+    ('shop', 'doityourself'): 'shopping_diy',
+    ('shop', 'estate_agent'): 'shopping_estateagent2',
+    ('shop', 'car'): 'shopping_car',
+    ('shop', 'garden_centre'): 'shopping_garden_centre',
+    ('shop', 'car_repair'): 'shopping_car_repair',
+    ('shop', 'bakery'): 'shopping_bakery',
+    ('shop', 'butcher'): 'shopping_butcher',
+    ('shop', 'apparel'): 'shopping_clothes',
+    ('shop', 'laundry'): 'shopping_laundrette',
+    ('shop', 'beverages'): 'shopping_alcohol',
+    ('shop', 'alcohol'): 'shopping_alcohol',
+    ('shop', 'optician'): 'health_opticians',
+    ('shop', 'chemist'): 'health_pharmacy',
+    ('shop', 'gallery'): 'tourist_art_gallery2',
+    ('shop', 'jewelry'): 'shopping_jewelry',
+    ('tourism', 'information'): 'amenity_information',
+    ('historic', 'ruins'): 'tourist_ruin',
+    ('amenity', 'college'): 'education_school',
+    ('historic', 'monument'): 'tourist_monument',
+    ('historic', 'memorial'): 'tourist_monument',
+    ('historic', 'mine'): 'poi_mine',
+    ('tourism', 'caravan_site'): 'accommodation_caravan_park',
+    ('amenity', 'bus_station'): 'transport_bus_station',
+    ('amenity', 'atm'): 'money_atm2',
+    ('tourism', 'viewpoint'): 'tourist_view_point',
+    ('tourism', 'guesthouse'): 'accommodation_bed_and_breakfast',
+    ('railway', 'tram'): 'transport_tram_stop',
+    ('amenity', 'courthouse'): 'amenity_court',
+    ('amenity', 'recycling'): 'amenity_recycling',
+    ('amenity', 'dentist'): 'health_dentist',
+    ('natural', 'beach'): 'tourist_beach',
+    ('railway', 'tram_stop'): 'transport_tram_stop',
+    ('amenity', 'prison'): 'amenity_prison',
+    ('highway', 'bus_stop'): 'transport_bus_stop2'
+}
index 116e2ae634e671a7ac191431e705dfd19d46da38..3643af83f13287efa2e26aac1e396f271f8d05d5 100644 (file)
@@ -7,22 +7,26 @@
 """
 Output formatters for API version v1.
 """
+from typing import Mapping, Any
+import collections
+
+import nominatim.api as napi
 from nominatim.api.result_formatting import FormatDispatcher
-from nominatim.api import StatusResult
+from nominatim.api.v1.classtypes import ICONS
 from nominatim.utils.json_writer import JsonWriter
 
 dispatch = FormatDispatcher()
 
-@dispatch.format_func(StatusResult, 'text')
-def _format_status_text(result: StatusResult) -> str:
+@dispatch.format_func(napi.StatusResult, 'text')
+def _format_status_text(result: napi.StatusResult, _: Mapping[str, Any]) -> str:
     if result.status:
         return f"ERROR: {result.message}"
 
     return 'OK'
 
 
-@dispatch.format_func(StatusResult, 'json')
-def _format_status_json(result: StatusResult) -> str:
+@dispatch.format_func(napi.StatusResult, 'json')
+def _format_status_json(result: napi.StatusResult, _: Mapping[str, Any]) -> str:
     out = JsonWriter()
 
     out.start_object()\
@@ -35,3 +39,125 @@ def _format_status_json(result: StatusResult) -> str:
        .end_object()
 
     return out()
+
+
+def _add_address_row(writer: JsonWriter, row: napi.AddressLine,
+                     locales: napi.Locales) -> None:
+    writer.start_object()\
+            .keyval('localname', locales.display_name(row.names))\
+            .keyval('place_id', row.place_id)
+
+    if row.osm_object is not None:
+        writer.keyval('osm_id', row.osm_object[1])\
+              .keyval('osm_type', row.osm_object[0])
+
+    if row.extratags:
+        writer.keyval_not_none('place_type', row.extratags.get('place_type'))
+
+    writer.keyval('class', row.category[0])\
+          .keyval('type', row.category[1])\
+          .keyval_not_none('admin_level', row.admin_level)\
+          .keyval('rank_address', row.rank_address)\
+          .keyval('distance', row.distance)\
+          .keyval('isaddress', row.isaddress)\
+        .end_object()
+
+
+def _add_address_rows(writer: JsonWriter, section: str, rows: napi.AddressLines,
+                      locales: napi.Locales) -> None:
+    writer.key(section).start_array()
+    for row in rows:
+        _add_address_row(writer, row, locales)
+        writer.next()
+    writer.end_array().next()
+
+
+def _add_parent_rows_grouped(writer: JsonWriter, rows: napi.AddressLines,
+                             locales: napi.Locales) -> None:
+    # group by category type
+    data = collections.defaultdict(list)
+    for row in rows:
+        sub = JsonWriter()
+        _add_address_row(sub, row, locales)
+        data[row.category[1]].append(sub())
+
+    writer.key('hierarchy').start_object()
+    for group, grouped in data.items():
+        writer.key(group).start_array()
+        grouped.sort() # sorts alphabetically by local name
+        for line in grouped:
+            writer.raw(line).next()
+        writer.end_array().next()
+
+    writer.end_object().next()
+
+
+@dispatch.format_func(napi.SearchResult, 'details-json')
+def _format_search_json(result: napi.SearchResult, options: Mapping[str, Any]) -> str:
+    locales = options.get('locales', napi.Locales())
+    geom = result.geometry.get('geojson')
+    centroid = result.centroid_as_geojson()
+
+    out = JsonWriter()
+    out.start_object()\
+         .keyval('place_id', result.place_id)\
+         .keyval('parent_place_id', result.parent_place_id)
+
+    if result.osm_object is not None:
+        out.keyval('osm_type', result.osm_object[0])\
+           .keyval('osm_id', result.osm_object[1])
+
+    out.keyval('category', result.category[0])\
+         .keyval('type', result.category[1])\
+         .keyval('admin_level', result.admin_level)\
+         .keyval('localname', locales.display_name(result.names))\
+         .keyval('names', result.names or [])\
+         .keyval('addresstags', result.address or [])\
+         .keyval('housenumber', result.housenumber)\
+         .keyval('calculated_postcode', result.postcode)\
+         .keyval('country_code', result.country_code)\
+         .keyval_not_none('indexed_date', result.indexed_date, lambda v: v.isoformat())\
+         .keyval('importance', result.importance)\
+         .keyval('calculated_importance', result.calculated_importance())\
+         .keyval('extratags', result.extratags or [])\
+         .keyval('calculated_wikipedia', result.wikipedia)\
+         .keyval('rank_address', result.rank_address)\
+         .keyval('rank_search', result.rank_search)\
+         .keyval('isarea', 'Polygon' in (geom or result.geometry.get('type') or ''))\
+         .key('centroid').raw(centroid).next()\
+         .key('geometry').raw(geom or centroid).next()
+
+    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 result.address_rows is not None:
+        _add_address_rows(out, 'address', result.address_rows, locales)
+
+    if result.linked_rows is not None:
+        _add_address_rows(out, 'linked_places', result.linked_rows, locales)
+
+    if result.name_keywords is not None or result.address_keywords is not None:
+        out.key('keywords').start_object()
+
+        for sec, klist in (('name', result.name_keywords), ('address', result.address_keywords)):
+            out.key(sec).start_array()
+            for word in (klist or []):
+                out.start_object()\
+                     .keyval('id', word.word_id)\
+                     .keyval('token', word.word_token)\
+                   .end_object().next()
+            out.end_array().next()
+
+        out.end_object().next()
+
+    if result.parented_rows is not None:
+        if options.get('group_hierarchy', False):
+            _add_parent_rows_grouped(out, result.parented_rows, locales)
+        else:
+            _add_address_rows(out, 'hierarchy', result.parented_rows, locales)
+
+    out.end_object()
+
+    return out()
index 7444b7aa07869ab126f45ab144d6d53a75d1fc47..52ce747cd2be640a1f4e5b8f3b4fef11572c6133 100644 (file)
@@ -143,7 +143,7 @@ async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> A
     else:
         status_code = 200
 
-    return params.build_response(formatting.format_result(result, fmt), fmt,
+    return params.build_response(formatting.format_result(result, fmt, {}), fmt,
                                  status=status_code)
 
 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
index cc65f5f6e357f2fed10f08067a130c21395957f1..523013a66ba512fa534973d6bb56c25aa26f56b6 100644 (file)
@@ -10,11 +10,13 @@ Subcommand definitions for API calls from the command line.
 from typing import Mapping, Dict
 import argparse
 import logging
+import json
+import sys
 
 from nominatim.tools.exec_utils import run_api_script
 from nominatim.errors import UsageError
 from nominatim.clicmd.args import NominatimArgs
-from nominatim.api import NominatimAPI, StatusResult
+import nominatim.api as napi
 import nominatim.api.v1 as api_output
 
 # Do not repeat documentation of subcommand classes.
@@ -38,15 +40,6 @@ EXTRADATA_PARAMS = (
     ('namedetails', 'Include a list of alternative names')
 )
 
-DETAILS_SWITCHES = (
-    ('addressdetails', 'Include a breakdown of the address into elements'),
-    ('keywords', 'Include a list of name keywords and address keywords'),
-    ('linkedplaces', 'Include a details of places that are linked with this one'),
-    ('hierarchy', 'Include details of places lower in the address hierarchy'),
-    ('group_hierarchy', 'Group the places by type'),
-    ('polygon_geojson', 'Include geometry of result')
-)
-
 def _add_api_output_arguments(parser: argparse.ArgumentParser) -> None:
     group = parser.add_argument_group('Output arguments')
     group.add_argument('--format', default='jsonv2',
@@ -240,29 +233,66 @@ class APIDetails:
                                  "of the same object."))
 
         group = parser.add_argument_group('Output arguments')
-        for name, desc in DETAILS_SWITCHES:
-            group.add_argument('--' + name, action='store_true', help=desc)
+        group.add_argument('--addressdetails', action='store_true',
+                           help='Include a breakdown of the address into elements')
+        group.add_argument('--keywords', action='store_true',
+                           help='Include a list of name keywords and address keywords')
+        group.add_argument('--linkedplaces', action='store_true',
+                           help='Include a details of places that are linked with this one')
+        group.add_argument('--hierarchy', action='store_true',
+                           help='Include details of places lower in the address hierarchy')
+        group.add_argument('--group_hierarchy', action='store_true',
+                           help='Group the places by type')
+        group.add_argument('--polygon_geojson', action='store_true',
+                           help='Include geometry of result')
         group.add_argument('--lang', '--accept-language', metavar='LANGS',
                            help='Preferred language order for presenting search results')
 
 
     def run(self, args: NominatimArgs) -> int:
+        place: napi.PlaceRef
         if args.node:
-            params = dict(osmtype='N', osmid=args.node)
+            place = napi.OsmID('N', args.node, args.object_class)
         elif args.way:
-            params = dict(osmtype='W', osmid=args.way)
+            place = napi.OsmID('W', args.way, args.object_class)
         elif args.relation:
-            params = dict(osmtype='R', osmid=args.relation)
+            place = napi.OsmID('R', args.relation, args.object_class)
         else:
-            params = dict(place_id=args.place_id)
-        if args.object_class:
-            params['class'] = args.object_class
-        for name, _ in DETAILS_SWITCHES:
-            params[name] = '1' if getattr(args, name) else '0'
+            assert args.place_id is not None
+            place = napi.PlaceID(args.place_id)
+
+        api = napi.NominatimAPI(args.project_dir)
+
+        details = napi.LookupDetails(address_details=args.addressdetails,
+                                     linked_places=args.linkedplaces,
+                                     parented_places=args.hierarchy,
+                                     keywords=args.keywords)
+        if args.polygon_geojson:
+            details.geometry_output = napi.GeometryFormat.GEOJSON
+
         if args.lang:
-            params['accept-language'] = args.lang
+            locales = napi.Locales.from_accept_languages(args.lang)
+        elif api.config.DEFAULT_LANGUAGE:
+            locales = napi.Locales.from_accept_languages(api.config.DEFAULT_LANGUAGE)
+        else:
+            locales = napi.Locales()
+
+        result = api.lookup(place, details)
+
+        if result:
+            output = api_output.format_result(
+                        result,
+                        'details-json',
+                        {'locales': locales,
+                         'group_hierarchy': args.group_hierarchy})
+            # reformat the result, so it is pretty-printed
+            json.dump(json.loads(output), sys.stdout, indent=4)
+            sys.stdout.write('\n')
+
+            return 0
 
-        return _run_api('details', args, params)
+        LOG.error("Object not found in database.")
+        return 42
 
 
 class APIStatus:
@@ -276,13 +306,13 @@ class APIStatus:
     """
 
     def add_args(self, parser: argparse.ArgumentParser) -> None:
-        formats = api_output.list_formats(StatusResult)
+        formats = api_output.list_formats(napi.StatusResult)
         group = parser.add_argument_group('API parameters')
         group.add_argument('--format', default=formats[0], choices=formats,
                            help='Format of result')
 
 
     def run(self, args: NominatimArgs) -> int:
-        status = NominatimAPI(args.project_dir).status()
-        print(api_output.format_result(status, args.format))
+        status = napi.NominatimAPI(args.project_dir).status()
+        print(api_output.format_result(status, args.format, {}))
         return 0
index e47287b33dff17c7f62c335ad5dc9ed08b6236e8..9be20b20f31708bdb3ba69c6bd279c13a8ee2c27 100644 (file)
@@ -168,6 +168,11 @@ class NominatimArgs:
 
     # Arguments to 'details'
     object_class: Optional[str]
+    linkedplaces: bool
+    hierarchy: bool
+    keywords: bool
+    polygon_geojson: bool
+    group_hierarchy: bool
 
 
     def osm2pgsql_options(self, default_cache: int,
diff --git a/test/python/api/test_localization.py b/test/python/api/test_localization.py
new file mode 100644 (file)
index 0000000..b704e5a
--- /dev/null
@@ -0,0 +1,53 @@
+# 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.
+"""
+Test functions for adapting results to the user's locale.
+"""
+import pytest
+
+from nominatim.api import Locales
+
+def test_display_name_empty_names():
+    l = Locales(['en', 'de'])
+
+    assert l.display_name(None) == ''
+    assert l.display_name({}) == ''
+
+def test_display_name_none_localized():
+    l = Locales()
+
+    assert l.display_name({}) == ''
+    assert l.display_name({'name:de': 'DE', 'name': 'ALL'}) == 'ALL'
+    assert l.display_name({'ref': '34', 'name:de': 'DE'}) == '34'
+
+
+def test_display_name_localized():
+    l = Locales(['en', 'de'])
+
+    assert l.display_name({}) == ''
+    assert l.display_name({'name:de': 'DE', 'name': 'ALL'}) == 'DE'
+    assert l.display_name({'ref': '34', 'name:de': 'DE'}) == 'DE'
+
+
+def test_display_name_preference():
+    l = Locales(['en', 'de'])
+
+    assert l.display_name({}) == ''
+    assert l.display_name({'name:de': 'DE', 'name:en': 'EN'}) == 'EN'
+    assert l.display_name({'official_name:en': 'EN', 'name:de': 'DE'}) == 'DE'
+
+
+@pytest.mark.parametrize('langstr,langlist',
+                         [('fr', ['fr']),
+                          ('fr-FR', ['fr-FR', 'fr']),
+                          ('de,fr-FR', ['de', 'fr-FR', 'fr']),
+                          ('fr,de,fr-FR', ['fr', 'de', 'fr-FR']),
+                          ('en;q=0.5,fr', ['fr', 'en']),
+                          ('en;q=0.5,fr,en-US', ['fr', 'en-US', 'en']),
+                          ('en,fr;garbage,de', ['en', 'de'])])
+def test_from_language_preferences(langstr, langlist):
+    assert Locales.from_accept_languages(langstr).languages == langlist
index 4a5d59895a7741f2195ebcff7a9b3624c2b10059..01cca04954f494e9329124458895a1a74abde9ce 100644 (file)
@@ -32,17 +32,17 @@ def test_status_unsupported():
 
 
 def test_status_format_text():
-    assert api_impl.format_result(StatusResult(0, 'message here'), 'text') == 'OK'
+    assert api_impl.format_result(StatusResult(0, 'message here'), 'text', {}) == 'OK'
 
 
 def test_status_format_text():
-    assert api_impl.format_result(StatusResult(500, 'message here'), 'text') == 'ERROR: message here'
+    assert api_impl.format_result(StatusResult(500, 'message here'), 'text', {}) == 'ERROR: message here'
 
 
 def test_status_format_json_minimal():
     status = StatusResult(700, 'Bad format.')
 
-    result = api_impl.format_result(status, 'json')
+    result = api_impl.format_result(status, 'json', {})
 
     assert result == '{"status":700,"message":"Bad format.","software_version":"%s"}' % (NOMINATIM_VERSION, )
 
@@ -52,6 +52,6 @@ def test_status_format_json_full():
     status.data_updated = dt.datetime(2010, 2, 7, 20, 20, 3, 0, tzinfo=dt.timezone.utc)
     status.database_version = '5.6'
 
-    result = api_impl.format_result(status, 'json')
+    result = api_impl.format_result(status, 'json', {})
 
     assert result == '{"status":0,"message":"OK","data_updated":"2010-02-07T20:20:03+00:00","software_version":"%s","database_version":"5.6"}' % (NOMINATIM_VERSION, )
index b0c2411f845c3f19661f9bb9f8007af97380108c..966059c480ddf627cc3989c9a835dea8f40d0995 100644 (file)
@@ -25,11 +25,7 @@ def test_no_api_without_phpcgi(endpoint):
 @pytest.mark.parametrize("params", [('search', '--query', 'new'),
                                     ('search', '--city', 'Berlin'),
                                     ('reverse', '--lat', '0', '--lon', '0', '--zoom', '13'),
-                                    ('lookup', '--id', 'N1'),
-                                    ('details', '--node', '1'),
-                                    ('details', '--way', '1'),
-                                    ('details', '--relation', '1'),
-                                    ('details', '--place_id', '10001')])
+                                    ('lookup', '--id', 'N1')])
 class TestCliApiCallPhp:
 
     @pytest.fixture(autouse=True)
@@ -79,6 +75,29 @@ class TestCliStatusCall:
         json.loads(capsys.readouterr().out)
 
 
+class TestCliDetailsCall:
+
+    @pytest.fixture(autouse=True)
+    def setup_status_mock(self, monkeypatch):
+        result = napi.SearchResult(napi.SourceTable.PLACEX, ('place', 'thing'),
+                                   (1.0, -3.0))
+
+        monkeypatch.setattr(napi.NominatimAPI, 'lookup',
+                            lambda *args: result)
+
+    @pytest.mark.parametrize("params", [('--node', '1'),
+                                        ('--way', '1'),
+                                        ('--relation', '1'),
+                                        ('--place_id', '10001')])
+
+    def test_status_json_format(self, cli_call, tmp_path, capsys, params):
+        result = cli_call('details', '--project-dir', str(tmp_path), *params)
+
+        assert result == 0
+
+        json.loads(capsys.readouterr().out)
+
+
 QUERY_PARAMS = {
  'search': ('--query', 'somewhere'),
  'reverse': ('--lat', '20', '--lon', '30'),
@@ -157,27 +176,3 @@ def test_cli_search_param_dedupe(cli_call, project_env):
 
     assert cli_call('search', *QUERY_PARAMS['search'], '--project-dir', str(project_env.project_dir),
                     '--no-dedupe') == 0
-
-
-def test_cli_details_param_class(cli_call, project_env):
-    webdir = project_env.project_dir / 'website'
-    webdir.mkdir()
-    (webdir / 'details.php').write_text(f"""<?php
-        exit($_GET['class']  == 'highway' ? 0 : 10);
-        """)
-
-    assert cli_call('details', *QUERY_PARAMS['details'], '--project-dir', str(project_env.project_dir),
-                    '--class', 'highway') == 0
-
-
-@pytest.mark.parametrize('param', ('lang', 'accept-language'))
-def test_cli_details_param_lang(cli_call, project_env, param):
-    webdir = project_env.project_dir / 'website'
-    webdir.mkdir()
-    (webdir / 'details.php').write_text(f"""<?php
-        exit($_GET['accept-language']  == 'es' ? 0 : 10);
-        """)
-
-    assert cli_call('details', *QUERY_PARAMS['details'], '--project-dir', str(project_env.project_dir),
-                    '--' + param, 'es') == 0
-