From: Sarah Hoffmann Date: Fri, 7 Apr 2023 12:04:58 +0000 (+0200) Subject: Merge remote-tracking branch 'upstream/master' X-Git-Tag: deploy~71 X-Git-Url: https://git.openstreetmap.org./nominatim.git/commitdiff_plain/7284689b500e2af43692983cf07d4b3d2649e02b?hp=e1af6a22d357de51c86ac73582beba2b4419227b Merge remote-tracking branch 'upstream/master' --- diff --git a/lib-sql/functions/interpolation.sql b/lib-sql/functions/interpolation.sql index 9bb91021..928d55c5 100644 --- a/lib-sql/functions/interpolation.sql +++ b/lib-sql/functions/interpolation.sql @@ -164,7 +164,7 @@ DECLARE newend INTEGER; moddiff SMALLINT; linegeo GEOMETRY; - splitline GEOMETRY; + splitpoint FLOAT; sectiongeo GEOMETRY; postcode TEXT; stepmod SMALLINT; @@ -223,15 +223,27 @@ BEGIN FROM placex, generate_series(1, array_upper(waynodes, 1)) nodeidpos WHERE osm_type = 'N' and osm_id = waynodes[nodeidpos]::BIGINT and address is not NULL and address ? 'housenumber' + and ST_Distance(NEW.linegeo, geometry) < 0.0005 ORDER BY nodeidpos LOOP {% if debug %}RAISE WARNING 'processing point % (%)', nextnode.hnr, ST_AsText(nextnode.geometry);{% endif %} IF linegeo is null THEN linegeo := NEW.linegeo; ELSE - splitline := ST_Split(ST_Snap(linegeo, nextnode.geometry, 0.0005), nextnode.geometry); - sectiongeo := ST_GeometryN(splitline, 1); - linegeo := ST_GeometryN(splitline, 2); + splitpoint := ST_LineLocatePoint(linegeo, nextnode.geometry); + IF splitpoint = 0 THEN + -- Corner case where the splitpoint falls on the first point + -- and thus would not return a geometry. Skip that section. + sectiongeo := NULL; + ELSEIF splitpoint = 1 THEN + -- Point is at the end of the line. + sectiongeo := linegeo; + linegeo := NULL; + ELSE + -- Split the line. + sectiongeo := ST_LineSubstring(linegeo, 0, splitpoint); + linegeo := ST_LineSubstring(linegeo, splitpoint, 1); + END IF; END IF; IF prevnode.hnr is not null @@ -239,6 +251,9 @@ BEGIN -- regularly mapped housenumbers. -- (Conveniently also fails if one of the house numbers is not a number.) and abs(prevnode.hnr - nextnode.hnr) > NEW.step + -- If the interpolation geometry is broken or two nodes are at the + -- same place, then splitting might produce a point. Ignore that. + and ST_GeometryType(sectiongeo) = 'ST_LineString' THEN IF prevnode.hnr < nextnode.hnr THEN startnumber := prevnode.hnr; @@ -300,12 +315,12 @@ BEGIN NEW.address, postcode, NEW.country_code, NEW.geometry_sector, 0); END IF; + END IF; - -- early break if we are out of line string, - -- might happen when a line string loops back on itself - IF ST_GeometryType(linegeo) != 'ST_LineString' THEN - RETURN NEW; - END IF; + -- early break if we are out of line string, + -- might happen when a line string loops back on itself + IF linegeo is null or ST_GeometryType(linegeo) != 'ST_LineString' THEN + RETURN NEW; END IF; prevnode := nextnode; diff --git a/lib-sql/functions/utils.sql b/lib-sql/functions/utils.sql index ad262670..f5be7b61 100644 --- a/lib-sql/functions/utils.sql +++ b/lib-sql/functions/utils.sql @@ -429,9 +429,10 @@ BEGIN SELECT osm_type, osm_id, class, type FROM placex WHERE place_id = placeid INTO osmtype, osmid, pclass, ptype; DELETE FROM import_polygon_delete where osm_type = osmtype and osm_id = osmid and class = pclass and type = ptype; DELETE FROM import_polygon_error where osm_type = osmtype and osm_id = osmid and class = pclass and type = ptype; - -- force delete from place/placex by making it a very small geometry - UPDATE place set geometry = ST_SetSRID(ST_Point(0,0), 4326) where osm_type = osmtype and osm_id = osmid and class = pclass and type = ptype; - DELETE FROM place where osm_type = osmtype and osm_id = osmid and class = pclass and type = ptype; + -- force delete by directly entering it into the to-be-deleted table + INSERT INTO place_to_be_deleted (osm_type, osm_id, class, type, deferred) + VALUES(osmtype, osmid, pclass, ptype, false); + PERFORM flush_deleted_places(); RETURN TRUE; END; diff --git a/nominatim/api/__init__.py b/nominatim/api/__init__.py index 0a91e281..9f362379 100644 --- a/nominatim/api/__init__.py +++ b/nominatim/api/__init__.py @@ -32,5 +32,7 @@ from .results import (SourceTable as SourceTable, WordInfos as WordInfos, DetailedResult as DetailedResult, ReverseResult as ReverseResult, - ReverseResults as ReverseResults) + ReverseResults as ReverseResults, + SearchResult as SearchResult, + SearchResults as SearchResults) from .localization import (Locales as Locales) diff --git a/nominatim/api/core.py b/nominatim/api/core.py index 6d47d332..29325b08 100644 --- a/nominatim/api/core.py +++ b/nominatim/api/core.py @@ -7,7 +7,7 @@ """ Implementation of classes for API access via libraries. """ -from typing import Mapping, Optional, Any, AsyncIterator, Dict +from typing import Mapping, Optional, Any, AsyncIterator, Dict, Sequence import asyncio import contextlib from pathlib import Path @@ -20,10 +20,10 @@ from nominatim.db.sqlalchemy_schema import SearchTables from nominatim.config import Configuration from nominatim.api.connection import SearchConnection from nominatim.api.status import get_status, StatusResult -from nominatim.api.lookup import get_place_by_id +from nominatim.api.lookup import get_detailed_place, get_simple_place from nominatim.api.reverse import ReverseGeocoder from nominatim.api.types import PlaceRef, LookupDetails, AnyPoint, DataLayer -from nominatim.api.results import DetailedResult, ReverseResult +from nominatim.api.results import DetailedResult, ReverseResult, SearchResults class NominatimAPIAsync: @@ -130,14 +130,27 @@ class NominatimAPIAsync: return status - async def lookup(self, place: PlaceRef, - details: Optional[LookupDetails] = None) -> Optional[DetailedResult]: + async def details(self, place: PlaceRef, + details: Optional[LookupDetails] = None) -> Optional[DetailedResult]: """ Get detailed information about a place in the database. Returns None if there is no entry under the given ID. """ async with self.begin() as conn: - return await get_place_by_id(conn, place, details or LookupDetails()) + return await get_detailed_place(conn, place, details or LookupDetails()) + + + async def lookup(self, places: Sequence[PlaceRef], + details: Optional[LookupDetails] = None) -> SearchResults: + """ Get simple information about a list of places. + + Returns a list of place information for all IDs that were found. + """ + if details is None: + details = LookupDetails() + async with self.begin() as conn: + return SearchResults(filter(None, + [await get_simple_place(conn, p, details) for p in places])) async def reverse(self, coord: AnyPoint, max_rank: Optional[int] = None, @@ -195,11 +208,20 @@ class NominatimAPI: return self._loop.run_until_complete(self._async_api.status()) - def lookup(self, place: PlaceRef, - details: Optional[LookupDetails] = None) -> Optional[DetailedResult]: + def details(self, place: PlaceRef, + details: Optional[LookupDetails] = None) -> Optional[DetailedResult]: """ Get detailed information about a place in the database. """ - return self._loop.run_until_complete(self._async_api.lookup(place, details)) + return self._loop.run_until_complete(self._async_api.details(place, details)) + + + def lookup(self, places: Sequence[PlaceRef], + details: Optional[LookupDetails] = None) -> SearchResults: + """ Get simple information about a list of places. + + Returns a list of place information for all IDs that were found. + """ + return self._loop.run_until_complete(self._async_api.lookup(places, details)) def reverse(self, coord: AnyPoint, max_rank: Optional[int] = None, diff --git a/nominatim/api/lookup.py b/nominatim/api/lookup.py index 1b0ee87f..82352702 100644 --- a/nominatim/api/lookup.py +++ b/nominatim/api/lookup.py @@ -7,34 +7,25 @@ """ Implementation of place lookup by ID. """ -from typing import Optional +from typing import Optional, Callable, Tuple, Type import datetime as dt import sqlalchemy as sa -from nominatim.typing import SaColumn, SaLabel, SaRow +from nominatim.typing import SaColumn, SaRow, SaSelect from nominatim.api.connection import SearchConnection import nominatim.api.types as ntyp import nominatim.api.results as nres from nominatim.api.logging import log -def _select_column_geometry(column: SaColumn, - geometry_output: ntyp.GeometryFormat) -> SaLabel: - """ Create the appropriate column expression for selecting a - geometry for the details response. - """ - if geometry_output & ntyp.GeometryFormat.GEOJSON: - return sa.literal_column(f""" - ST_AsGeoJSON(CASE WHEN ST_NPoints({column.name}) > 5000 - THEN ST_SimplifyPreserveTopology({column.name}, 0.0001) - ELSE {column.name} END) - """).label('geometry_geojson') +RowFunc = Callable[[Optional[SaRow], Type[nres.BaseResultT]], Optional[nres.BaseResultT]] + +GeomFunc = Callable[[SaSelect, SaColumn], SaSelect] - return sa.func.ST_GeometryType(column).label('geometry_type') async def find_in_placex(conn: SearchConnection, place: ntyp.PlaceRef, - details: ntyp.LookupDetails) -> Optional[SaRow]: + add_geometries: GeomFunc) -> Optional[SaRow]: """ Search for the given place in the placex table and return the base information. """ @@ -47,8 +38,7 @@ async def find_in_placex(conn: SearchConnection, place: ntyp.PlaceRef, t.c.importance, t.c.wikipedia, t.c.indexed_date, t.c.parent_place_id, t.c.rank_address, t.c.rank_search, t.c.linked_place_id, - t.c.centroid, - _select_column_geometry(t.c.geometry, details.geometry_output)) + t.c.centroid) if isinstance(place, ntyp.PlaceID): sql = sql.where(t.c.place_id == place.place_id) @@ -63,11 +53,11 @@ async def find_in_placex(conn: SearchConnection, place: ntyp.PlaceRef, else: return None - return (await conn.execute(sql)).one_or_none() + return (await conn.execute(add_geometries(sql, t.c.geometry))).one_or_none() async def find_in_osmline(conn: SearchConnection, place: ntyp.PlaceRef, - details: ntyp.LookupDetails) -> Optional[SaRow]: + add_geometries: GeomFunc) -> Optional[SaRow]: """ Search for the given place in the osmline table and return the base information. """ @@ -76,8 +66,7 @@ async def find_in_osmline(conn: SearchConnection, place: ntyp.PlaceRef, sql = sa.select(t.c.place_id, t.c.osm_id, t.c.parent_place_id, t.c.indexed_date, t.c.startnumber, t.c.endnumber, t.c.step, t.c.address, t.c.postcode, t.c.country_code, - t.c.linegeo.ST_Centroid().label('centroid'), - _select_column_geometry(t.c.linegeo, details.geometry_output)) + t.c.linegeo.ST_Centroid().label('centroid')) if isinstance(place, ntyp.PlaceID): sql = sql.where(t.c.place_id == place.place_id) @@ -92,14 +81,17 @@ async def find_in_osmline(conn: SearchConnection, place: ntyp.PlaceRef, else: return None - return (await conn.execute(sql)).one_or_none() + return (await conn.execute(add_geometries(sql, t.c.linegeo))).one_or_none() async def find_in_tiger(conn: SearchConnection, place: ntyp.PlaceRef, - details: ntyp.LookupDetails) -> Optional[SaRow]: + add_geometries: GeomFunc) -> Optional[SaRow]: """ Search for the given place in the table of Tiger addresses and return the base information. Only lookup by place ID is supported. """ + if not isinstance(place, ntyp.PlaceID): + return None + log().section("Find in TIGER table") t = conn.t.tiger parent = conn.t.placex @@ -107,69 +99,86 @@ async def find_in_tiger(conn: SearchConnection, place: ntyp.PlaceRef, parent.c.osm_type, parent.c.osm_id, t.c.startnumber, t.c.endnumber, t.c.step, t.c.postcode, - t.c.linegeo.ST_Centroid().label('centroid'), - _select_column_geometry(t.c.linegeo, details.geometry_output)) + t.c.linegeo.ST_Centroid().label('centroid'))\ + .where(t.c.place_id == place.place_id)\ + .join(parent, t.c.parent_place_id == parent.c.place_id, isouter=True) - if isinstance(place, ntyp.PlaceID): - sql = sql.where(t.c.place_id == place.place_id)\ - .join(parent, t.c.parent_place_id == parent.c.place_id, isouter=True) - else: - return None - - return (await conn.execute(sql)).one_or_none() + return (await conn.execute(add_geometries(sql, t.c.linegeo))).one_or_none() async def find_in_postcode(conn: SearchConnection, place: ntyp.PlaceRef, - details: ntyp.LookupDetails) -> Optional[SaRow]: + add_geometries: GeomFunc) -> Optional[SaRow]: """ Search for the given place in the postcode table and return the base information. Only lookup by place ID is supported. """ + if not isinstance(place, ntyp.PlaceID): + return None + log().section("Find in postcode table") t = conn.t.postcode sql = sa.select(t.c.place_id, t.c.parent_place_id, t.c.rank_search, t.c.rank_address, t.c.indexed_date, t.c.postcode, t.c.country_code, - t.c.geometry.label('centroid'), - _select_column_geometry(t.c.geometry, details.geometry_output)) + t.c.geometry.label('centroid')) \ + .where(t.c.place_id == place.place_id) - if isinstance(place, ntyp.PlaceID): - sql = sql.where(t.c.place_id == place.place_id) - else: - return None + return (await conn.execute(add_geometries(sql, t.c.geometry))).one_or_none() - return (await conn.execute(sql)).one_or_none() +async def find_in_all_tables(conn: SearchConnection, place: ntyp.PlaceRef, + add_geometries: GeomFunc + ) -> Tuple[Optional[SaRow], RowFunc[nres.BaseResultT]]: + """ Search for the given place in all data tables + and return the base information. + """ + row = await find_in_placex(conn, place, add_geometries) + log().var_dump('Result (placex)', row) + if row is not None: + return row, nres.create_from_placex_row -async def get_place_by_id(conn: SearchConnection, place: ntyp.PlaceRef, - details: ntyp.LookupDetails) -> Optional[nres.DetailedResult]: + row = await find_in_osmline(conn, place, add_geometries) + log().var_dump('Result (osmline)', row) + if row is not None: + return row, nres.create_from_osmline_row + + row = await find_in_postcode(conn, place, add_geometries) + log().var_dump('Result (postcode)', row) + if row is not None: + return row, nres.create_from_postcode_row + + row = await find_in_tiger(conn, place, add_geometries) + log().var_dump('Result (tiger)', row) + return row, nres.create_from_tiger_row + + +async def get_detailed_place(conn: SearchConnection, place: ntyp.PlaceRef, + details: ntyp.LookupDetails) -> Optional[nres.DetailedResult]: """ Retrieve a place with additional details from the database. """ - log().function('get_place_by_id', place=place, details=details) + log().function('get_detailed_place', place=place, details=details) if details.geometry_output and details.geometry_output != ntyp.GeometryFormat.GEOJSON: raise ValueError("lookup only supports geojosn polygon output.") - row = await find_in_placex(conn, place, details) - log().var_dump('Result (placex)', row) - if row is not None: - result = nres.create_from_placex_row(row, nres.DetailedResult) + if details.geometry_output & ntyp.GeometryFormat.GEOJSON: + def _add_geometry(sql: SaSelect, column: SaColumn) -> SaSelect: + return sql.add_columns(sa.literal_column(f""" + ST_AsGeoJSON(CASE WHEN ST_NPoints({column.name}) > 5000 + THEN ST_SimplifyPreserveTopology({column.name}, 0.0001) + ELSE {column.name} END) + """).label('geometry_geojson')) else: - row = await find_in_osmline(conn, place, details) - log().var_dump('Result (osmline)', row) - if row is not None: - result = nres.create_from_osmline_row(row, nres.DetailedResult) - else: - row = await find_in_postcode(conn, place, details) - log().var_dump('Result (postcode)', row) - if row is not None: - result = nres.create_from_postcode_row(row, nres.DetailedResult) - else: - row = await find_in_tiger(conn, place, details) - log().var_dump('Result (tiger)', row) - if row is not None: - result = nres.create_from_tiger_row(row, nres.DetailedResult) - else: - return None + def _add_geometry(sql: SaSelect, column: SaColumn) -> SaSelect: + return sql.add_columns(sa.func.ST_GeometryType(column).label('geometry_type')) + + row_func: RowFunc[nres.DetailedResult] + row, row_func = await find_in_all_tables(conn, place, _add_geometry) + + if row is None: + return None + + result = row_func(row, nres.DetailedResult) + assert result is not None # add missing details assert result is not None @@ -183,3 +192,48 @@ async def get_place_by_id(conn: SearchConnection, place: ntyp.PlaceRef, await nres.add_result_details(conn, result, details) return result + + +async def get_simple_place(conn: SearchConnection, place: ntyp.PlaceRef, + details: ntyp.LookupDetails) -> Optional[nres.SearchResult]: + """ Retrieve a place as a simple search result from the database. + """ + log().function('get_simple_place', place=place, details=details) + + def _add_geometry(sql: SaSelect, col: SaColumn) -> SaSelect: + if not details.geometry_output: + return sql + + out = [] + + if details.geometry_simplification > 0.0: + col = col.ST_SimplifyPreserveTopology(details.geometry_simplification) + + if details.geometry_output & ntyp.GeometryFormat.GEOJSON: + out.append(col.ST_AsGeoJSON().label('geometry_geojson')) + if details.geometry_output & ntyp.GeometryFormat.TEXT: + out.append(col.ST_AsText().label('geometry_text')) + if details.geometry_output & ntyp.GeometryFormat.KML: + out.append(col.ST_AsKML().label('geometry_kml')) + if details.geometry_output & ntyp.GeometryFormat.SVG: + out.append(col.ST_AsSVG().label('geometry_svg')) + + return sql.add_columns(*out) + + + row_func: RowFunc[nres.SearchResult] + row, row_func = await find_in_all_tables(conn, place, _add_geometry) + + if row is None: + return None + + result = row_func(row, nres.SearchResult) + assert result is not None + + # add missing details + assert result is not None + result.bbox = getattr(row, 'bbox', None) + + await nres.add_result_details(conn, result, details) + + return result diff --git a/nominatim/api/results.py b/nominatim/api/results.py index 098851ef..98b13380 100644 --- a/nominatim/api/results.py +++ b/nominatim/api/results.py @@ -173,6 +173,19 @@ class ReverseResults(List[ReverseResult]): """ +@dataclasses.dataclass +class SearchResult(BaseResult): + """ A search result for forward geocoding. + """ + bbox: Optional[Bbox] = None + + +class SearchResults(List[SearchResult]): + """ Sequence of forward lookup results ordered by relevance. + 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_')} diff --git a/nominatim/api/v1/classtypes.py b/nominatim/api/v1/classtypes.py index 27faa174..273fe2f5 100644 --- a/nominatim/api/v1/classtypes.py +++ b/nominatim/api/v1/classtypes.py @@ -10,7 +10,7 @@ Hard-coded information about tag catagories. These tables have been copied verbatim from the old PHP code. For future version a more flexible formatting is required. """ -from typing import Tuple, Optional, Mapping +from typing import Tuple, Optional, Mapping, Union import nominatim.api as napi @@ -41,7 +41,7 @@ def get_label_tag(category: Tuple[str, str], extratags: Optional[Mapping[str, st return label.lower().replace(' ', '_') -def bbox_from_result(result: napi.ReverseResult) -> napi.Bbox: +def bbox_from_result(result: Union[napi.ReverseResult, napi.SearchResult]) -> 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 diff --git a/nominatim/api/v1/format.py b/nominatim/api/v1/format.py index 47d2af4d..2e1caa99 100644 --- a/nominatim/api/v1/format.py +++ b/nominatim/api/v1/format.py @@ -195,3 +195,36 @@ def _format_reverse_jsonv2(results: napi.ReverseResults, options: Mapping[str, Any]) -> str: return format_json.format_base_json(results, options, True, class_label='category') + + +@dispatch.format_func(napi.SearchResults, 'xml') +def _format_search_xml(results: napi.SearchResults, options: Mapping[str, Any]) -> str: + return format_xml.format_base_xml(results, + options, False, 'searchresults', + {'querystring': 'TODO'}) + + +@dispatch.format_func(napi.SearchResults, 'geojson') +def _format_search_geojson(results: napi.SearchResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_geojson(results, options, False) + + +@dispatch.format_func(napi.SearchResults, 'geocodejson') +def _format_search_geocodejson(results: napi.SearchResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_geocodejson(results, options, False) + + +@dispatch.format_func(napi.SearchResults, 'json') +def _format_search_json(results: napi.SearchResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_json(results, options, False, + class_label='class') + + +@dispatch.format_func(napi.SearchResults, 'jsonv2') +def _format_search_jsonv2(results: napi.SearchResults, + options: Mapping[str, Any]) -> str: + return format_json.format_base_json(results, options, False, + class_label='category') diff --git a/nominatim/api/v1/format_json.py b/nominatim/api/v1/format_json.py index ef5f5280..c82681e9 100644 --- a/nominatim/api/v1/format_json.py +++ b/nominatim/api/v1/format_json.py @@ -7,12 +7,14 @@ """ Helper functions for output of results in json formats. """ -from typing import Mapping, Any, Optional, Tuple +from typing import Mapping, Any, Optional, Tuple, Union import nominatim.api as napi import nominatim.api.v1.classtypes as cl from nominatim.utils.json_writer import JsonWriter +#pylint: disable=too-many-branches + 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', cl.OSM_TYPE_NAME.get(osm_object[0], None))\ @@ -61,7 +63,7 @@ def _write_geocodejson_address(out: JsonWriter, out.keyval('country_code', country_code) -def format_base_json(results: napi.ReverseResults, #pylint: disable=too-many-branches +def format_base_json(results: Union[napi.ReverseResults, napi.SearchResults], options: Mapping[str, Any], simple: bool, class_label: str) -> str: """ Return the result list as a simple json string in custom Nominatim format. @@ -141,7 +143,7 @@ def format_base_json(results: napi.ReverseResults, #pylint: disable=too-many-bra return out() -def format_base_geojson(results: napi.ReverseResults, +def format_base_geojson(results: Union[napi.ReverseResults, napi.SearchResults], options: Mapping[str, Any], simple: bool) -> str: """ Return the result list as a geojson string. @@ -210,7 +212,7 @@ def format_base_geojson(results: napi.ReverseResults, return out() -def format_base_geocodejson(results: napi.ReverseResults, +def format_base_geocodejson(results: Union[napi.ReverseResults, napi.SearchResults], options: Mapping[str, Any], simple: bool) -> str: """ Return the result list as a geocodejson string. """ @@ -249,7 +251,7 @@ def format_base_geocodejson(results: napi.ReverseResults, 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, transform=int)\ + .keyval_not_none('accuracy', getattr(result, 'distance', None), transform=int)\ .keyval('label', ', '.join(label_parts))\ .keyval_not_none('name', result.names, transform=locales.display_name)\ diff --git a/nominatim/api/v1/format_xml.py b/nominatim/api/v1/format_xml.py index 3fe3b7fe..1fd0675a 100644 --- a/nominatim/api/v1/format_xml.py +++ b/nominatim/api/v1/format_xml.py @@ -7,13 +7,15 @@ """ Helper functions for output of results in XML format. """ -from typing import Mapping, Any, Optional +from typing import Mapping, Any, Optional, Union import datetime as dt import xml.etree.ElementTree as ET import nominatim.api as napi import nominatim.api.v1.classtypes as cl +#pylint: disable=too-many-branches + def _write_xml_address(root: ET.Element, address: napi.AddressLines, country_code: Optional[str]) -> None: parts = {} @@ -34,7 +36,7 @@ def _write_xml_address(root: ET.Element, address: napi.AddressLines, ET.SubElement(root, 'country_code').text = country_code -def _create_base_entry(result: napi.ReverseResult, #pylint: disable=too-many-branches +def _create_base_entry(result: Union[napi.ReverseResult, napi.SearchResult], root: ET.Element, simple: bool, locales: napi.Locales) -> ET.Element: if result.address_rows: @@ -86,7 +88,7 @@ def _create_base_entry(result: napi.ReverseResult, #pylint: disable=too-many-bra return place -def format_base_xml(results: napi.ReverseResults, +def format_base_xml(results: Union[napi.ReverseResults, napi.SearchResults], options: Mapping[str, Any], simple: bool, xml_root_tag: str, xml_extra_info: Mapping[str, str]) -> str: diff --git a/nominatim/api/v1/server_glue.py b/nominatim/api/v1/server_glue.py index a87b6825..68cf58c2 100644 --- a/nominatim/api/v1/server_glue.py +++ b/nominatim/api/v1/server_glue.py @@ -226,6 +226,33 @@ class ASGIAdaptor(abc.ABC): return fmt + def parse_geometry_details(self, fmt: str) -> napi.LookupDetails: + """ Create details strucutre from the supplied geometry parameters. + """ + details = napi.LookupDetails(address_details=True, + geometry_simplification= + self.get_float('polygon_threshold', 0.0)) + numgeoms = 0 + if self.get_bool('polygon_geojson', False): + details.geometry_output |= napi.GeometryFormat.GEOJSON + numgeoms += 1 + if fmt not in ('geojson', 'geocodejson'): + if self.get_bool('polygon_text', False): + details.geometry_output |= napi.GeometryFormat.TEXT + numgeoms += 1 + if self.get_bool('polygon_kml', False): + details.geometry_output |= napi.GeometryFormat.KML + numgeoms += 1 + if self.get_bool('polygon_svg', False): + details.geometry_output |= napi.GeometryFormat.SVG + numgeoms += 1 + + if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'): + self.raise_error('Too many polgyon output options selected.') + + return details + + async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any: """ Server glue for /status endpoint. See API docs for details. """ @@ -268,7 +295,7 @@ async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> locales = napi.Locales.from_accept_languages(params.get_accepted_languages()) - result = await api.lookup(place, details) + result = await api.details(place, details) if debug: return params.build_response(loglib.get_and_disable()) @@ -291,28 +318,10 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> debug = params.setup_debugging() coord = napi.Point(params.get_float('lon'), params.get_float('lat')) locales = napi.Locales.from_accept_languages(params.get_accepted_languages()) + details = params.parse_geometry_details(fmt) zoom = max(0, min(18, params.get_int('zoom', 18))) - details = napi.LookupDetails(address_details=True, - geometry_simplification=params.get_float('polygon_threshold', 0.0)) - numgeoms = 0 - if params.get_bool('polygon_geojson', False): - details.geometry_output |= napi.GeometryFormat.GEOJSON - numgeoms += 1 - if fmt not in ('geojson', 'geocodejson'): - if params.get_bool('polygon_text', False): - details.geometry_output |= napi.GeometryFormat.TEXT - numgeoms += 1 - if params.get_bool('polygon_kml', False): - details.geometry_output |= napi.GeometryFormat.KML - numgeoms += 1 - if params.get_bool('polygon_svg', False): - details.geometry_output |= napi.GeometryFormat.SVG - numgeoms += 1 - - if numgeoms > params.config().get_int('POLYGON_OUTPUT_MAX_TYPES'): - params.raise_error('Too many polgyon output options selected.') result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom], params.get_layers() or @@ -326,9 +335,6 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> 'extratags': params.get_bool('extratags', False), 'namedetails': params.get_bool('namedetails', False), 'addressdetails': params.get_bool('addressdetails', True)} - if fmt == 'xml': - fmt_options['xml_roottag'] = 'reversegeocode' - fmt_options['xml_extra_info'] = {'querystring': 'TODO'} output = formatting.format_result(napi.ReverseResults([result] if result else []), fmt, fmt_options) @@ -336,6 +342,37 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> return params.build_response(output) +async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any: + """ Server glue for /lookup endpoint. See API docs for details. + """ + fmt = params.parse_format(napi.SearchResults, 'xml') + debug = params.setup_debugging() + locales = napi.Locales.from_accept_languages(params.get_accepted_languages()) + details = params.parse_geometry_details(fmt) + + places = [] + for oid in (params.get('osm_ids') or '').split(','): + oid = oid.strip() + if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit(): + places.append(napi.OsmID(oid[0], int(oid[1:]))) + + if places: + results = await api.lookup(places, details) + else: + results = napi.SearchResults() + + if debug: + return params.build_response(loglib.get_and_disable()) + + fmt_options = {'locales': locales, + 'extratags': params.get_bool('extratags', False), + 'namedetails': params.get_bool('namedetails', False), + 'addressdetails': params.get_bool('addressdetails', True)} + + output = formatting.format_result(results, fmt, fmt_options) + + return params.build_response(output) + EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any] REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea @@ -357,5 +394,6 @@ REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea ROUTES = [ ('status', status_endpoint), ('details', details_endpoint), - ('reverse', reverse_endpoint) + ('reverse', reverse_endpoint), + ('lookup', lookup_endpoint) ] diff --git a/nominatim/clicmd/api.py b/nominatim/clicmd/api.py index 41256b79..58edbea4 100644 --- a/nominatim/clicmd/api.py +++ b/nominatim/clicmd/api.py @@ -214,19 +214,31 @@ class APILookup: def run(self, args: NominatimArgs) -> int: - params: Dict[str, object] = dict(osm_ids=','.join(args.ids), format=args.format) + api = napi.NominatimAPI(args.project_dir) - for param, _ in EXTRADATA_PARAMS: - if getattr(args, param): - params[param] = '1' - if args.lang: - params['accept-language'] = args.lang - if args.polygon_output: - params['polygon_' + args.polygon_output] = '1' - if args.polygon_threshold: - params['polygon_threshold'] = args.polygon_threshold + details = napi.LookupDetails(address_details=True, # needed for display name + geometry_output=args.get_geometry_output(), + geometry_simplification=args.polygon_threshold or 0.0) + + places = [napi.OsmID(o[0], int(o[1:])) for o in args.ids] + + results = api.lookup(places, details) - return _run_api('lookup', args, params) + output = api_output.format_result( + results, + args.format, + {'locales': args.get_locales(api.config.DEFAULT_LANGUAGE), + 'extratags': args.extratags, + 'namedetails': args.namedetails, + 'addressdetails': args.addressdetails}) + if args.format != 'xml': + # reformat the result, so it is pretty-printed + json.dump(json.loads(output), sys.stdout, indent=4, ensure_ascii=False) + else: + sys.stdout.write(output) + sys.stdout.write('\n') + + return 0 class APIDetails: @@ -292,7 +304,7 @@ class APIDetails: if args.polygon_geojson: details.geometry_output = napi.GeometryFormat.GEOJSON - result = api.lookup(place, details) + result = api.details(place, details) if result: output = api_output.format_result( diff --git a/test/bdd/db/import/interpolation.feature b/test/bdd/db/import/interpolation.feature index 0b939f59..3c67d652 100644 --- a/test/bdd/db/import/interpolation.feature +++ b/test/bdd/db/import/interpolation.feature @@ -29,14 +29,14 @@ Feature: Import of address interpolations | N2 | place | house | 8 | And the places | osm | class | type | addr+interpolation | geometry | - | W1 | place | houses | even | 1,2 | + | W1 | place | houses | even | 2,1 | And the ways | id | nodes | | 1 | 2,1 | When importing Then W1 expands to interpolation | start | end | geometry | - | 4 | 6 | 8,9 | + | 4 | 6 | 9,8 | Scenario: Simple odd two point interpolation Given the grid with origin 1,1 @@ -341,7 +341,7 @@ Feature: Import of address interpolations Then W1 expands to interpolation | start | end | geometry | | 4 | 4 | 144.963016 -37.762946 | - | 8 | 8 | 144.963144 -37.7622237 | + | 8 | 8 | 144.96314407 -37.762223692 | Scenario: Place with missing address information Given the grid @@ -456,3 +456,69 @@ Feature: Import of address interpolations | foo | | x | | 12-2 | + + + Scenario: Interpolation line where points have been moved (Github #3022) + Given the 0.00001 grid + | 1 | | | | | | | | 2 | 3 | 9 | | | | | | | | 4 | + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 1 | + | N2 | place | house | 18 | 3 | + | N3 | place | house | 24 | 9 | + | N4 | place | house | 42 | 4 | + And the places + | osm | class | type | addr+interpolation | geometry | + | W1 | place | houses | even | 1,2,3,4 | + And the ways + | id | nodes | + | 1 | 1,2,3,4 | + When importing + Then W1 expands to interpolation + | start | end | + | 4 | 16 | + | 20 | 22 | + | 26 | 40 | + + + Scenario: Interpolation line with duplicated points + Given the grid + | 7 | 10 | 8 | 11 | 9 | + Given the places + | osm | class | type | housenr | geometry | + | N1 | place | house | 2 | 7 | + | N2 | place | house | 6 | 8 | + | N3 | place | house | 10 | 8 | + | N4 | place | house | 14 | 9 | + And the places + | osm | class | type | addr+interpolation | geometry | + | W1 | place | houses | even | 7,8,8,9 | + And the ways + | id | nodes | + | 1 | 1,2,3,4 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 4 | 4 | 10 | + | 12 | 12 | 11 | + + + Scenario: Interpolaton line with broken way geometry (Github #2986) + Given the grid + | 1 | 8 | 10 | 11 | 9 | 2 | 3 | 4 | + Given the places + | osm | class | type | housenr | + | N1 | place | house | 2 | + | N2 | place | house | 8 | + | N3 | place | house | 12 | + | N4 | place | house | 14 | + And the places + | osm | class | type | addr+interpolation | geometry | + | W1 | place | houses | even | 8,9 | + And the ways + | id | nodes | + | 1 | 1,8,9,2,3,4 | + When importing + Then W1 expands to interpolation + | start | end | geometry | + | 4 | 6 | 10,11 | diff --git a/test/python/api/test_api_details.py b/test/python/api/test_api_details.py new file mode 100644 index 00000000..625c4e7a --- /dev/null +++ b/test/python/api/test_api_details.py @@ -0,0 +1,584 @@ +# 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. +""" +Tests for details API call. +""" +import datetime as dt + +import pytest + +import nominatim.api as napi + +@pytest.mark.parametrize('idobj', (napi.PlaceID(332), napi.OsmID('W', 4), + napi.OsmID('W', 4, 'highway'))) +def test_lookup_in_placex(apiobj, idobj): + import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', + name={'name': 'Road'}, address={'city': 'Barrow'}, + extratags={'surface': 'paved'}, + parent_place_id=34, linked_place_id=55, + admin_level=15, country_code='gb', + housenumber='4', + postcode='34425', wikipedia='en:Faa', + rank_search=27, rank_address=26, + importance=0.01, + centroid=(23, 34), + indexed_date=import_date, + geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)') + + result = apiobj.api.details(idobj, napi.LookupDetails()) + + assert result is not None + + assert result.source_table.name == 'PLACEX' + assert result.category == ('highway', 'residential') + assert result.centroid == (pytest.approx(23.0), pytest.approx(34.0)) + + assert result.place_id == 332 + assert result.parent_place_id == 34 + assert result.linked_place_id == 55 + assert result.osm_object == ('W', 4) + assert result.admin_level == 15 + + assert result.names == {'name': 'Road'} + assert result.address == {'city': 'Barrow'} + assert result.extratags == {'surface': 'paved'} + + assert result.housenumber == '4' + assert result.postcode == '34425' + assert result.wikipedia == 'en:Faa' + + assert result.rank_search == 27 + assert result.rank_address == 26 + assert result.importance == pytest.approx(0.01) + + assert result.country_code == 'gb' + assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) + + assert result.address_rows is None + assert result.linked_rows is None + assert result.parented_rows is None + assert result.name_keywords is None + assert result.address_keywords is None + + assert result.geometry == {'type': 'ST_LineString'} + + +def test_lookup_in_placex_minimal_info(apiobj): + import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', + admin_level=15, + rank_search=27, rank_address=26, + centroid=(23, 34), + indexed_date=import_date, + geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)') + + result = apiobj.api.details(napi.PlaceID(332), napi.LookupDetails()) + + assert result is not None + + assert result.source_table.name == 'PLACEX' + assert result.category == ('highway', 'residential') + assert result.centroid == (pytest.approx(23.0), pytest.approx(34.0)) + + assert result.place_id == 332 + assert result.parent_place_id is None + assert result.linked_place_id is None + assert result.osm_object == ('W', 4) + assert result.admin_level == 15 + + assert result.names is None + assert result.address is None + assert result.extratags is None + + assert result.housenumber is None + assert result.postcode is None + assert result.wikipedia is None + + assert result.rank_search == 27 + assert result.rank_address == 26 + assert result.importance is None + + assert result.country_code is None + assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) + + assert result.address_rows is None + assert result.linked_rows is None + assert result.parented_rows is None + assert result.name_keywords is None + assert result.address_keywords is None + + assert result.geometry == {'type': 'ST_LineString'} + + +def test_lookup_in_placex_with_geometry(apiobj): + apiobj.add_placex(place_id=332, + geometry='LINESTRING(23 34, 23.1 34)') + + result = apiobj.api.details(napi.PlaceID(332), + napi.LookupDetails(geometry_output=napi.GeometryFormat.GEOJSON)) + + assert result.geometry == {'geojson': '{"type":"LineString","coordinates":[[23,34],[23.1,34]]}'} + + +def test_lookup_placex_with_address_details(apiobj): + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', name='Street', + country_code='pl', + rank_search=27, rank_address=26) + apiobj.add_address_placex(332, fromarea=False, isaddress=False, + distance=0.0034, + place_id=1000, osm_type='N', osm_id=3333, + class_='place', type='suburb', name='Smallplace', + country_code='pl', admin_level=13, + rank_search=24, rank_address=23) + apiobj.add_address_placex(332, fromarea=True, isaddress=True, + place_id=1001, osm_type='N', osm_id=3334, + class_='place', type='city', name='Bigplace', + country_code='pl', + rank_search=17, rank_address=16) + + result = apiobj.api.details(napi.PlaceID(332), + napi.LookupDetails(address_details=True)) + + assert result.address_rows == [ + napi.AddressLine(place_id=332, osm_object=('W', 4), + category=('highway', 'residential'), + names={'name': 'Street'}, extratags={}, + admin_level=15, fromarea=True, isaddress=True, + rank_address=26, distance=0.0), + napi.AddressLine(place_id=1000, osm_object=('N', 3333), + category=('place', 'suburb'), + names={'name': 'Smallplace'}, extratags={}, + admin_level=13, fromarea=False, isaddress=True, + rank_address=23, distance=0.0034), + napi.AddressLine(place_id=1001, osm_object=('N', 3334), + category=('place', 'city'), + names={'name': 'Bigplace'}, extratags={}, + admin_level=15, fromarea=True, isaddress=True, + rank_address=16, distance=0.0), + napi.AddressLine(place_id=None, osm_object=None, + category=('place', 'country_code'), + names={'ref': 'pl'}, extratags={}, + admin_level=None, fromarea=True, isaddress=False, + rank_address=4, distance=0.0) + ] + + +def test_lookup_place_with_linked_places_none_existing(apiobj): + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', name='Street', + country_code='pl', linked_place_id=45, + rank_search=27, rank_address=26) + + result = apiobj.api.details(napi.PlaceID(332), + napi.LookupDetails(linked_places=True)) + + assert result.linked_rows == [] + + +def test_lookup_place_with_linked_places_existing(apiobj): + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', name='Street', + country_code='pl', linked_place_id=45, + rank_search=27, rank_address=26) + apiobj.add_placex(place_id=1001, osm_type='W', osm_id=5, + class_='highway', type='residential', name='Street', + country_code='pl', linked_place_id=332, + rank_search=27, rank_address=26) + apiobj.add_placex(place_id=1002, osm_type='W', osm_id=6, + class_='highway', type='residential', name='Street', + country_code='pl', linked_place_id=332, + rank_search=27, rank_address=26) + + result = apiobj.api.details(napi.PlaceID(332), + napi.LookupDetails(linked_places=True)) + + assert result.linked_rows == [ + napi.AddressLine(place_id=1001, osm_object=('W', 5), + category=('highway', 'residential'), + names={'name': 'Street'}, extratags={}, + admin_level=15, fromarea=False, isaddress=True, + rank_address=26, distance=0.0), + napi.AddressLine(place_id=1002, osm_object=('W', 6), + category=('highway', 'residential'), + names={'name': 'Street'}, extratags={}, + admin_level=15, fromarea=False, isaddress=True, + rank_address=26, distance=0.0), + ] + + +def test_lookup_place_with_parented_places_not_existing(apiobj): + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', name='Street', + country_code='pl', parent_place_id=45, + rank_search=27, rank_address=26) + + result = apiobj.api.details(napi.PlaceID(332), + napi.LookupDetails(parented_places=True)) + + assert result.parented_rows == [] + + +def test_lookup_place_with_parented_places_existing(apiobj): + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', name='Street', + country_code='pl', parent_place_id=45, + rank_search=27, rank_address=26) + apiobj.add_placex(place_id=1001, osm_type='N', osm_id=5, + class_='place', type='house', housenumber='23', + country_code='pl', parent_place_id=332, + rank_search=30, rank_address=30) + apiobj.add_placex(place_id=1002, osm_type='W', osm_id=6, + class_='highway', type='residential', name='Street', + country_code='pl', parent_place_id=332, + rank_search=27, rank_address=26) + + result = apiobj.api.details(napi.PlaceID(332), + napi.LookupDetails(parented_places=True)) + + assert result.parented_rows == [ + napi.AddressLine(place_id=1001, osm_object=('N', 5), + category=('place', 'house'), + names={'housenumber': '23'}, extratags={}, + admin_level=15, fromarea=False, isaddress=True, + rank_address=30, distance=0.0), + ] + + +@pytest.mark.parametrize('idobj', (napi.PlaceID(4924), napi.OsmID('W', 9928))) +def test_lookup_in_osmline(apiobj, idobj): + import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) + apiobj.add_osmline(place_id=4924, osm_id=9928, + parent_place_id=12, + startnumber=1, endnumber=4, step=1, + country_code='gb', postcode='34425', + address={'city': 'Big'}, + indexed_date=import_date, + geometry='LINESTRING(23 34, 23 35)') + + result = apiobj.api.details(idobj, napi.LookupDetails()) + + assert result is not None + + assert result.source_table.name == 'OSMLINE' + assert result.category == ('place', 'houses') + assert result.centroid == (pytest.approx(23.0), pytest.approx(34.5)) + + assert result.place_id == 4924 + assert result.parent_place_id == 12 + assert result.linked_place_id is None + assert result.osm_object == ('W', 9928) + assert result.admin_level == 15 + + assert result.names is None + assert result.address == {'city': 'Big'} + assert result.extratags == {'startnumber': '1', 'endnumber': '4', 'step': '1'} + + assert result.housenumber is None + assert result.postcode == '34425' + assert result.wikipedia is None + + assert result.rank_search == 30 + assert result.rank_address == 30 + assert result.importance is None + + assert result.country_code == 'gb' + assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) + + assert result.address_rows is None + assert result.linked_rows is None + assert result.parented_rows is None + assert result.name_keywords is None + assert result.address_keywords is None + + assert result.geometry == {'type': 'ST_LineString'} + + +def test_lookup_in_osmline_split_interpolation(apiobj): + apiobj.add_osmline(place_id=1000, osm_id=9, + startnumber=2, endnumber=4, step=1) + apiobj.add_osmline(place_id=1001, osm_id=9, + startnumber=6, endnumber=9, step=1) + apiobj.add_osmline(place_id=1002, osm_id=9, + startnumber=11, endnumber=20, step=1) + + for i in range(1, 6): + result = apiobj.api.details(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) + assert result.place_id == 1000 + for i in range(7, 11): + result = apiobj.api.details(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) + assert result.place_id == 1001 + for i in range(12, 22): + result = apiobj.api.details(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) + assert result.place_id == 1002 + + +def test_lookup_osmline_with_address_details(apiobj): + apiobj.add_osmline(place_id=9000, osm_id=9, + startnumber=2, endnumber=4, step=1, + parent_place_id=332) + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', name='Street', + country_code='pl', + rank_search=27, rank_address=26) + apiobj.add_address_placex(332, fromarea=False, isaddress=False, + distance=0.0034, + place_id=1000, osm_type='N', osm_id=3333, + class_='place', type='suburb', name='Smallplace', + country_code='pl', admin_level=13, + rank_search=24, rank_address=23) + apiobj.add_address_placex(332, fromarea=True, isaddress=True, + place_id=1001, osm_type='N', osm_id=3334, + class_='place', type='city', name='Bigplace', + country_code='pl', + rank_search=17, rank_address=16) + + result = apiobj.api.details(napi.PlaceID(9000), + napi.LookupDetails(address_details=True)) + + assert result.address_rows == [ + napi.AddressLine(place_id=None, osm_object=None, + category=('place', 'house_number'), + names={'ref': '2'}, extratags={}, + admin_level=None, fromarea=True, isaddress=True, + rank_address=28, distance=0.0), + napi.AddressLine(place_id=332, osm_object=('W', 4), + category=('highway', 'residential'), + names={'name': 'Street'}, extratags={}, + admin_level=15, fromarea=True, isaddress=True, + rank_address=26, distance=0.0), + napi.AddressLine(place_id=1000, osm_object=('N', 3333), + category=('place', 'suburb'), + names={'name': 'Smallplace'}, extratags={}, + admin_level=13, fromarea=False, isaddress=True, + rank_address=23, distance=0.0034), + napi.AddressLine(place_id=1001, osm_object=('N', 3334), + category=('place', 'city'), + names={'name': 'Bigplace'}, extratags={}, + admin_level=15, fromarea=True, isaddress=True, + rank_address=16, distance=0.0), + napi.AddressLine(place_id=None, osm_object=None, + category=('place', 'country_code'), + names={'ref': 'pl'}, extratags={}, + admin_level=None, fromarea=True, isaddress=False, + rank_address=4, distance=0.0) + ] + + +def test_lookup_in_tiger(apiobj): + apiobj.add_tiger(place_id=4924, + parent_place_id=12, + startnumber=1, endnumber=4, step=1, + postcode='34425', + geometry='LINESTRING(23 34, 23 35)') + apiobj.add_placex(place_id=12, + category=('highway', 'residential'), + osm_type='W', osm_id=6601223, + geometry='LINESTRING(23 34, 23 35)') + + result = apiobj.api.details(napi.PlaceID(4924), napi.LookupDetails()) + + assert result is not None + + assert result.source_table.name == 'TIGER' + assert result.category == ('place', 'houses') + assert result.centroid == (pytest.approx(23.0), pytest.approx(34.5)) + + assert result.place_id == 4924 + assert result.parent_place_id == 12 + assert result.linked_place_id is None + assert result.osm_object == ('W', 6601223) + assert result.admin_level == 15 + + assert result.names is None + assert result.address is None + assert result.extratags == {'startnumber': '1', 'endnumber': '4', 'step': '1'} + + assert result.housenumber is None + assert result.postcode == '34425' + assert result.wikipedia is None + + assert result.rank_search == 30 + assert result.rank_address == 30 + assert result.importance is None + + assert result.country_code == 'us' + assert result.indexed_date is None + + assert result.address_rows is None + assert result.linked_rows is None + assert result.parented_rows is None + assert result.name_keywords is None + assert result.address_keywords is None + + assert result.geometry == {'type': 'ST_LineString'} + + +def test_lookup_tiger_with_address_details(apiobj): + apiobj.add_tiger(place_id=9000, + startnumber=2, endnumber=4, step=1, + parent_place_id=332) + apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, + class_='highway', type='residential', name='Street', + country_code='us', + rank_search=27, rank_address=26) + apiobj.add_address_placex(332, fromarea=False, isaddress=False, + distance=0.0034, + place_id=1000, osm_type='N', osm_id=3333, + class_='place', type='suburb', name='Smallplace', + country_code='us', admin_level=13, + rank_search=24, rank_address=23) + apiobj.add_address_placex(332, fromarea=True, isaddress=True, + place_id=1001, osm_type='N', osm_id=3334, + class_='place', type='city', name='Bigplace', + country_code='us', + rank_search=17, rank_address=16) + + result = apiobj.api.details(napi.PlaceID(9000), + napi.LookupDetails(address_details=True)) + + assert result.address_rows == [ + napi.AddressLine(place_id=None, osm_object=None, + category=('place', 'house_number'), + names={'ref': '2'}, extratags={}, + admin_level=None, fromarea=True, isaddress=True, + rank_address=28, distance=0.0), + napi.AddressLine(place_id=332, osm_object=('W', 4), + category=('highway', 'residential'), + names={'name': 'Street'}, extratags={}, + admin_level=15, fromarea=True, isaddress=True, + rank_address=26, distance=0.0), + napi.AddressLine(place_id=1000, osm_object=('N', 3333), + category=('place', 'suburb'), + names={'name': 'Smallplace'}, extratags={}, + admin_level=13, fromarea=False, isaddress=True, + rank_address=23, distance=0.0034), + napi.AddressLine(place_id=1001, osm_object=('N', 3334), + category=('place', 'city'), + names={'name': 'Bigplace'}, extratags={}, + admin_level=15, fromarea=True, isaddress=True, + rank_address=16, distance=0.0), + napi.AddressLine(place_id=None, osm_object=None, + category=('place', 'country_code'), + names={'ref': 'us'}, extratags={}, + admin_level=None, fromarea=True, isaddress=False, + rank_address=4, distance=0.0) + ] + + +def test_lookup_in_postcode(apiobj): + import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) + apiobj.add_postcode(place_id=554, + parent_place_id=152, + postcode='34 425', + country_code='gb', + rank_search=20, rank_address=22, + indexed_date=import_date, + geometry='POINT(-9.45 5.6)') + + result = apiobj.api.details(napi.PlaceID(554), napi.LookupDetails()) + + assert result is not None + + assert result.source_table.name == 'POSTCODE' + assert result.category == ('place', 'postcode') + assert result.centroid == (pytest.approx(-9.45), pytest.approx(5.6)) + + assert result.place_id == 554 + assert result.parent_place_id == 152 + assert result.linked_place_id is None + assert result.osm_object is None + assert result.admin_level == 15 + + assert result.names == {'ref': '34 425'} + assert result.address is None + assert result.extratags is None + + assert result.housenumber is None + assert result.postcode is None + assert result.wikipedia is None + + assert result.rank_search == 20 + assert result.rank_address == 22 + assert result.importance is None + + assert result.country_code == 'gb' + assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) + + assert result.address_rows is None + assert result.linked_rows is None + assert result.parented_rows is None + assert result.name_keywords is None + assert result.address_keywords is None + + assert result.geometry == {'type': 'ST_Point'} + + +def test_lookup_postcode_with_address_details(apiobj): + apiobj.add_postcode(place_id=9000, + parent_place_id=332, + postcode='34 425', + country_code='gb', + rank_search=25, rank_address=25) + apiobj.add_placex(place_id=332, osm_type='N', osm_id=3333, + class_='place', type='suburb', name='Smallplace', + country_code='gb', admin_level=13, + rank_search=24, rank_address=23) + apiobj.add_address_placex(332, fromarea=True, isaddress=True, + place_id=1001, osm_type='N', osm_id=3334, + class_='place', type='city', name='Bigplace', + country_code='gb', + rank_search=17, rank_address=16) + + result = apiobj.api.details(napi.PlaceID(9000), + napi.LookupDetails(address_details=True)) + + assert result.address_rows == [ + napi.AddressLine(place_id=332, osm_object=('N', 3333), + category=('place', 'suburb'), + names={'name': 'Smallplace'}, extratags={}, + admin_level=13, fromarea=True, isaddress=True, + rank_address=23, distance=0.0), + napi.AddressLine(place_id=1001, osm_object=('N', 3334), + category=('place', 'city'), + names={'name': 'Bigplace'}, extratags={}, + admin_level=15, fromarea=True, isaddress=True, + rank_address=16, distance=0.0), + napi.AddressLine(place_id=None, osm_object=None, + category=('place', 'postcode'), + names={'ref': '34 425'}, extratags={}, + admin_level=None, fromarea=False, isaddress=True, + rank_address=5, distance=0.0), + napi.AddressLine(place_id=None, osm_object=None, + category=('place', 'country_code'), + names={'ref': 'gb'}, extratags={}, + admin_level=None, fromarea=True, isaddress=False, + rank_address=4, distance=0.0) + ] + +@pytest.mark.parametrize('objid', [napi.PlaceID(1736), + napi.OsmID('W', 55), + napi.OsmID('N', 55, 'amenity')]) +def test_lookup_missing_object(apiobj, objid): + apiobj.add_placex(place_id=1, osm_type='N', osm_id=55, + class_='place', type='suburb') + + assert apiobj.api.details(objid, napi.LookupDetails()) is None + + +@pytest.mark.parametrize('gtype', (napi.GeometryFormat.KML, + napi.GeometryFormat.SVG, + napi.GeometryFormat.TEXT)) +def test_lookup_unsupported_geometry(apiobj, gtype): + apiobj.add_placex(place_id=332) + + with pytest.raises(ValueError): + apiobj.api.details(napi.PlaceID(332), + napi.LookupDetails(geometry_output=gtype)) diff --git a/test/python/api/test_api_lookup.py b/test/python/api/test_api_lookup.py index 6939ddb9..6aafa29e 100644 --- a/test/python/api/test_api_lookup.py +++ b/test/python/api/test_api_lookup.py @@ -7,16 +7,22 @@ """ Tests for lookup API call. """ -import datetime as dt - import pytest import nominatim.api as napi +def test_lookup_empty_list(apiobj): + assert apiobj.api.lookup([]) == [] + + +def test_lookup_non_existing(apiobj): + assert apiobj.api.lookup((napi.PlaceID(332), napi.OsmID('W', 4), + napi.OsmID('W', 4, 'highway'))) == [] + + @pytest.mark.parametrize('idobj', (napi.PlaceID(332), napi.OsmID('W', 4), napi.OsmID('W', 4, 'highway'))) -def test_lookup_in_placex(apiobj, idobj): - import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) +def test_lookup_single_placex(apiobj, idobj): apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, class_='highway', type='residential', name={'name': 'Road'}, address={'city': 'Barrow'}, @@ -28,22 +34,20 @@ def test_lookup_in_placex(apiobj, idobj): rank_search=27, rank_address=26, importance=0.01, centroid=(23, 34), - indexed_date=import_date, geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)') - result = apiobj.api.lookup(idobj, napi.LookupDetails()) + result = apiobj.api.lookup([idobj]) - assert result is not None + assert len(result) == 1 + + result = result[0] assert result.source_table.name == 'PLACEX' assert result.category == ('highway', 'residential') assert result.centroid == (pytest.approx(23.0), pytest.approx(34.0)) assert result.place_id == 332 - assert result.parent_place_id == 34 - assert result.linked_place_id == 55 assert result.osm_object == ('W', 4) - assert result.admin_level == 15 assert result.names == {'name': 'Road'} assert result.address == {'city': 'Barrow'} @@ -58,7 +62,6 @@ def test_lookup_in_placex(apiobj, idobj): assert result.importance == pytest.approx(0.01) assert result.country_code == 'gb' - assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) assert result.address_rows is None assert result.linked_rows is None @@ -66,519 +69,34 @@ def test_lookup_in_placex(apiobj, idobj): assert result.name_keywords is None assert result.address_keywords is None - assert result.geometry == {'type': 'ST_LineString'} + assert result.geometry == {} -def test_lookup_in_placex_minimal_info(apiobj): - import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) +def test_lookup_multiple_places(apiobj): apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, class_='highway', type='residential', - admin_level=15, + name={'name': 'Road'}, address={'city': 'Barrow'}, + extratags={'surface': 'paved'}, + parent_place_id=34, linked_place_id=55, + admin_level=15, country_code='gb', + housenumber='4', + postcode='34425', wikipedia='en:Faa', rank_search=27, rank_address=26, + importance=0.01, centroid=(23, 34), - indexed_date=import_date, geometry='LINESTRING(23 34, 23.1 34, 23.1 34.1, 23 34)') - - result = apiobj.api.lookup(napi.PlaceID(332), napi.LookupDetails()) - - assert result is not None - - assert result.source_table.name == 'PLACEX' - assert result.category == ('highway', 'residential') - assert result.centroid == (pytest.approx(23.0), pytest.approx(34.0)) - - assert result.place_id == 332 - assert result.parent_place_id is None - assert result.linked_place_id is None - assert result.osm_object == ('W', 4) - assert result.admin_level == 15 - - assert result.names is None - assert result.address is None - assert result.extratags is None - - assert result.housenumber is None - assert result.postcode is None - assert result.wikipedia is None - - assert result.rank_search == 27 - assert result.rank_address == 26 - assert result.importance is None - - assert result.country_code is None - assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) - - assert result.address_rows is None - assert result.linked_rows is None - assert result.parented_rows is None - assert result.name_keywords is None - assert result.address_keywords is None - - assert result.geometry == {'type': 'ST_LineString'} - - -def test_lookup_in_placex_with_geometry(apiobj): - apiobj.add_placex(place_id=332, - geometry='LINESTRING(23 34, 23.1 34)') - - result = apiobj.api.lookup(napi.PlaceID(332), - napi.LookupDetails(geometry_output=napi.GeometryFormat.GEOJSON)) - - assert result.geometry == {'geojson': '{"type":"LineString","coordinates":[[23,34],[23.1,34]]}'} - - -def test_lookup_placex_with_address_details(apiobj): - apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, - class_='highway', type='residential', name='Street', - country_code='pl', - rank_search=27, rank_address=26) - apiobj.add_address_placex(332, fromarea=False, isaddress=False, - distance=0.0034, - place_id=1000, osm_type='N', osm_id=3333, - class_='place', type='suburb', name='Smallplace', - country_code='pl', admin_level=13, - rank_search=24, rank_address=23) - apiobj.add_address_placex(332, fromarea=True, isaddress=True, - place_id=1001, osm_type='N', osm_id=3334, - class_='place', type='city', name='Bigplace', - country_code='pl', - rank_search=17, rank_address=16) - - result = apiobj.api.lookup(napi.PlaceID(332), - napi.LookupDetails(address_details=True)) - - assert result.address_rows == [ - napi.AddressLine(place_id=332, osm_object=('W', 4), - category=('highway', 'residential'), - names={'name': 'Street'}, extratags={}, - admin_level=15, fromarea=True, isaddress=True, - rank_address=26, distance=0.0), - napi.AddressLine(place_id=1000, osm_object=('N', 3333), - category=('place', 'suburb'), - names={'name': 'Smallplace'}, extratags={}, - admin_level=13, fromarea=False, isaddress=True, - rank_address=23, distance=0.0034), - napi.AddressLine(place_id=1001, osm_object=('N', 3334), - category=('place', 'city'), - names={'name': 'Bigplace'}, extratags={}, - admin_level=15, fromarea=True, isaddress=True, - rank_address=16, distance=0.0), - napi.AddressLine(place_id=None, osm_object=None, - category=('place', 'country_code'), - names={'ref': 'pl'}, extratags={}, - admin_level=None, fromarea=True, isaddress=False, - rank_address=4, distance=0.0) - ] - - -def test_lookup_place_with_linked_places_none_existing(apiobj): - apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, - class_='highway', type='residential', name='Street', - country_code='pl', linked_place_id=45, - rank_search=27, rank_address=26) - - result = apiobj.api.lookup(napi.PlaceID(332), - napi.LookupDetails(linked_places=True)) - - assert result.linked_rows == [] - - -def test_lookup_place_with_linked_places_existing(apiobj): - apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, - class_='highway', type='residential', name='Street', - country_code='pl', linked_place_id=45, - rank_search=27, rank_address=26) - apiobj.add_placex(place_id=1001, osm_type='W', osm_id=5, - class_='highway', type='residential', name='Street', - country_code='pl', linked_place_id=332, - rank_search=27, rank_address=26) - apiobj.add_placex(place_id=1002, osm_type='W', osm_id=6, - class_='highway', type='residential', name='Street', - country_code='pl', linked_place_id=332, - rank_search=27, rank_address=26) - - result = apiobj.api.lookup(napi.PlaceID(332), - napi.LookupDetails(linked_places=True)) - - assert result.linked_rows == [ - napi.AddressLine(place_id=1001, osm_object=('W', 5), - category=('highway', 'residential'), - names={'name': 'Street'}, extratags={}, - admin_level=15, fromarea=False, isaddress=True, - rank_address=26, distance=0.0), - napi.AddressLine(place_id=1002, osm_object=('W', 6), - category=('highway', 'residential'), - names={'name': 'Street'}, extratags={}, - admin_level=15, fromarea=False, isaddress=True, - rank_address=26, distance=0.0), - ] - - -def test_lookup_place_with_parented_places_not_existing(apiobj): - apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, - class_='highway', type='residential', name='Street', - country_code='pl', parent_place_id=45, - rank_search=27, rank_address=26) - - result = apiobj.api.lookup(napi.PlaceID(332), - napi.LookupDetails(parented_places=True)) - - assert result.parented_rows == [] - - -def test_lookup_place_with_parented_places_existing(apiobj): - apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, - class_='highway', type='residential', name='Street', - country_code='pl', parent_place_id=45, - rank_search=27, rank_address=26) - apiobj.add_placex(place_id=1001, osm_type='N', osm_id=5, - class_='place', type='house', housenumber='23', - country_code='pl', parent_place_id=332, - rank_search=30, rank_address=30) - apiobj.add_placex(place_id=1002, osm_type='W', osm_id=6, - class_='highway', type='residential', name='Street', - country_code='pl', parent_place_id=332, - rank_search=27, rank_address=26) - - result = apiobj.api.lookup(napi.PlaceID(332), - napi.LookupDetails(parented_places=True)) - - assert result.parented_rows == [ - napi.AddressLine(place_id=1001, osm_object=('N', 5), - category=('place', 'house'), - names={'housenumber': '23'}, extratags={}, - admin_level=15, fromarea=False, isaddress=True, - rank_address=30, distance=0.0), - ] - - -@pytest.mark.parametrize('idobj', (napi.PlaceID(4924), napi.OsmID('W', 9928))) -def test_lookup_in_osmline(apiobj, idobj): - import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) apiobj.add_osmline(place_id=4924, osm_id=9928, parent_place_id=12, startnumber=1, endnumber=4, step=1, country_code='gb', postcode='34425', address={'city': 'Big'}, - indexed_date=import_date, geometry='LINESTRING(23 34, 23 35)') - result = apiobj.api.lookup(idobj, napi.LookupDetails()) - - assert result is not None - - assert result.source_table.name == 'OSMLINE' - assert result.category == ('place', 'houses') - assert result.centroid == (pytest.approx(23.0), pytest.approx(34.5)) - - assert result.place_id == 4924 - assert result.parent_place_id == 12 - assert result.linked_place_id is None - assert result.osm_object == ('W', 9928) - assert result.admin_level == 15 - - assert result.names is None - assert result.address == {'city': 'Big'} - assert result.extratags == {'startnumber': '1', 'endnumber': '4', 'step': '1'} - - assert result.housenumber is None - assert result.postcode == '34425' - assert result.wikipedia is None - - assert result.rank_search == 30 - assert result.rank_address == 30 - assert result.importance is None - - assert result.country_code == 'gb' - assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) - - assert result.address_rows is None - assert result.linked_rows is None - assert result.parented_rows is None - assert result.name_keywords is None - assert result.address_keywords is None - - assert result.geometry == {'type': 'ST_LineString'} - - -def test_lookup_in_osmline_split_interpolation(apiobj): - apiobj.add_osmline(place_id=1000, osm_id=9, - startnumber=2, endnumber=4, step=1) - apiobj.add_osmline(place_id=1001, osm_id=9, - startnumber=6, endnumber=9, step=1) - apiobj.add_osmline(place_id=1002, osm_id=9, - startnumber=11, endnumber=20, step=1) - - for i in range(1, 6): - result = apiobj.api.lookup(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) - assert result.place_id == 1000 - for i in range(7, 11): - result = apiobj.api.lookup(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) - assert result.place_id == 1001 - for i in range(12, 22): - result = apiobj.api.lookup(napi.OsmID('W', 9, str(i)), napi.LookupDetails()) - assert result.place_id == 1002 - - -def test_lookup_osmline_with_address_details(apiobj): - apiobj.add_osmline(place_id=9000, osm_id=9, - startnumber=2, endnumber=4, step=1, - parent_place_id=332) - apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, - class_='highway', type='residential', name='Street', - country_code='pl', - rank_search=27, rank_address=26) - apiobj.add_address_placex(332, fromarea=False, isaddress=False, - distance=0.0034, - place_id=1000, osm_type='N', osm_id=3333, - class_='place', type='suburb', name='Smallplace', - country_code='pl', admin_level=13, - rank_search=24, rank_address=23) - apiobj.add_address_placex(332, fromarea=True, isaddress=True, - place_id=1001, osm_type='N', osm_id=3334, - class_='place', type='city', name='Bigplace', - country_code='pl', - rank_search=17, rank_address=16) - - result = apiobj.api.lookup(napi.PlaceID(9000), - napi.LookupDetails(address_details=True)) - - assert result.address_rows == [ - napi.AddressLine(place_id=None, osm_object=None, - category=('place', 'house_number'), - names={'ref': '2'}, extratags={}, - admin_level=None, fromarea=True, isaddress=True, - rank_address=28, distance=0.0), - napi.AddressLine(place_id=332, osm_object=('W', 4), - category=('highway', 'residential'), - names={'name': 'Street'}, extratags={}, - admin_level=15, fromarea=True, isaddress=True, - rank_address=26, distance=0.0), - napi.AddressLine(place_id=1000, osm_object=('N', 3333), - category=('place', 'suburb'), - names={'name': 'Smallplace'}, extratags={}, - admin_level=13, fromarea=False, isaddress=True, - rank_address=23, distance=0.0034), - napi.AddressLine(place_id=1001, osm_object=('N', 3334), - category=('place', 'city'), - names={'name': 'Bigplace'}, extratags={}, - admin_level=15, fromarea=True, isaddress=True, - rank_address=16, distance=0.0), - napi.AddressLine(place_id=None, osm_object=None, - category=('place', 'country_code'), - names={'ref': 'pl'}, extratags={}, - admin_level=None, fromarea=True, isaddress=False, - rank_address=4, distance=0.0) - ] - - -def test_lookup_in_tiger(apiobj): - apiobj.add_tiger(place_id=4924, - parent_place_id=12, - startnumber=1, endnumber=4, step=1, - postcode='34425', - geometry='LINESTRING(23 34, 23 35)') - apiobj.add_placex(place_id=12, - category=('highway', 'residential'), - osm_type='W', osm_id=6601223, - geometry='LINESTRING(23 34, 23 35)') - - result = apiobj.api.lookup(napi.PlaceID(4924), napi.LookupDetails()) - - assert result is not None - - assert result.source_table.name == 'TIGER' - assert result.category == ('place', 'houses') - assert result.centroid == (pytest.approx(23.0), pytest.approx(34.5)) - - assert result.place_id == 4924 - assert result.parent_place_id == 12 - assert result.linked_place_id is None - assert result.osm_object == ('W', 6601223) - assert result.admin_level == 15 - - assert result.names is None - assert result.address is None - assert result.extratags == {'startnumber': '1', 'endnumber': '4', 'step': '1'} - - assert result.housenumber is None - assert result.postcode == '34425' - assert result.wikipedia is None - - assert result.rank_search == 30 - assert result.rank_address == 30 - assert result.importance is None - - assert result.country_code == 'us' - assert result.indexed_date is None - - assert result.address_rows is None - assert result.linked_rows is None - assert result.parented_rows is None - assert result.name_keywords is None - assert result.address_keywords is None - - assert result.geometry == {'type': 'ST_LineString'} - - -def test_lookup_tiger_with_address_details(apiobj): - apiobj.add_tiger(place_id=9000, - startnumber=2, endnumber=4, step=1, - parent_place_id=332) - apiobj.add_placex(place_id=332, osm_type='W', osm_id=4, - class_='highway', type='residential', name='Street', - country_code='us', - rank_search=27, rank_address=26) - apiobj.add_address_placex(332, fromarea=False, isaddress=False, - distance=0.0034, - place_id=1000, osm_type='N', osm_id=3333, - class_='place', type='suburb', name='Smallplace', - country_code='us', admin_level=13, - rank_search=24, rank_address=23) - apiobj.add_address_placex(332, fromarea=True, isaddress=True, - place_id=1001, osm_type='N', osm_id=3334, - class_='place', type='city', name='Bigplace', - country_code='us', - rank_search=17, rank_address=16) - - result = apiobj.api.lookup(napi.PlaceID(9000), - napi.LookupDetails(address_details=True)) - - assert result.address_rows == [ - napi.AddressLine(place_id=None, osm_object=None, - category=('place', 'house_number'), - names={'ref': '2'}, extratags={}, - admin_level=None, fromarea=True, isaddress=True, - rank_address=28, distance=0.0), - napi.AddressLine(place_id=332, osm_object=('W', 4), - category=('highway', 'residential'), - names={'name': 'Street'}, extratags={}, - admin_level=15, fromarea=True, isaddress=True, - rank_address=26, distance=0.0), - napi.AddressLine(place_id=1000, osm_object=('N', 3333), - category=('place', 'suburb'), - names={'name': 'Smallplace'}, extratags={}, - admin_level=13, fromarea=False, isaddress=True, - rank_address=23, distance=0.0034), - napi.AddressLine(place_id=1001, osm_object=('N', 3334), - category=('place', 'city'), - names={'name': 'Bigplace'}, extratags={}, - admin_level=15, fromarea=True, isaddress=True, - rank_address=16, distance=0.0), - napi.AddressLine(place_id=None, osm_object=None, - category=('place', 'country_code'), - names={'ref': 'us'}, extratags={}, - admin_level=None, fromarea=True, isaddress=False, - rank_address=4, distance=0.0) - ] - - -def test_lookup_in_postcode(apiobj): - import_date = dt.datetime(2022, 12, 7, 14, 14, 46, 0) - apiobj.add_postcode(place_id=554, - parent_place_id=152, - postcode='34 425', - country_code='gb', - rank_search=20, rank_address=22, - indexed_date=import_date, - geometry='POINT(-9.45 5.6)') - - result = apiobj.api.lookup(napi.PlaceID(554), napi.LookupDetails()) - - assert result is not None - - assert result.source_table.name == 'POSTCODE' - assert result.category == ('place', 'postcode') - assert result.centroid == (pytest.approx(-9.45), pytest.approx(5.6)) - - assert result.place_id == 554 - assert result.parent_place_id == 152 - assert result.linked_place_id is None - assert result.osm_object is None - assert result.admin_level == 15 - - assert result.names == {'ref': '34 425'} - assert result.address is None - assert result.extratags is None - - assert result.housenumber is None - assert result.postcode is None - assert result.wikipedia is None - - assert result.rank_search == 20 - assert result.rank_address == 22 - assert result.importance is None - - assert result.country_code == 'gb' - assert result.indexed_date == import_date.replace(tzinfo=dt.timezone.utc) - - assert result.address_rows is None - assert result.linked_rows is None - assert result.parented_rows is None - assert result.name_keywords is None - assert result.address_keywords is None - - assert result.geometry == {'type': 'ST_Point'} - - -def test_lookup_postcode_with_address_details(apiobj): - apiobj.add_postcode(place_id=9000, - parent_place_id=332, - postcode='34 425', - country_code='gb', - rank_search=25, rank_address=25) - apiobj.add_placex(place_id=332, osm_type='N', osm_id=3333, - class_='place', type='suburb', name='Smallplace', - country_code='gb', admin_level=13, - rank_search=24, rank_address=23) - apiobj.add_address_placex(332, fromarea=True, isaddress=True, - place_id=1001, osm_type='N', osm_id=3334, - class_='place', type='city', name='Bigplace', - country_code='gb', - rank_search=17, rank_address=16) - - result = apiobj.api.lookup(napi.PlaceID(9000), - napi.LookupDetails(address_details=True)) - - assert result.address_rows == [ - napi.AddressLine(place_id=332, osm_object=('N', 3333), - category=('place', 'suburb'), - names={'name': 'Smallplace'}, extratags={}, - admin_level=13, fromarea=True, isaddress=True, - rank_address=23, distance=0.0), - napi.AddressLine(place_id=1001, osm_object=('N', 3334), - category=('place', 'city'), - names={'name': 'Bigplace'}, extratags={}, - admin_level=15, fromarea=True, isaddress=True, - rank_address=16, distance=0.0), - napi.AddressLine(place_id=None, osm_object=None, - category=('place', 'postcode'), - names={'ref': '34 425'}, extratags={}, - admin_level=None, fromarea=False, isaddress=True, - rank_address=5, distance=0.0), - napi.AddressLine(place_id=None, osm_object=None, - category=('place', 'country_code'), - names={'ref': 'gb'}, extratags={}, - admin_level=None, fromarea=True, isaddress=False, - rank_address=4, distance=0.0) - ] - -@pytest.mark.parametrize('objid', [napi.PlaceID(1736), - napi.OsmID('W', 55), - napi.OsmID('N', 55, 'amenity')]) -def test_lookup_missing_object(apiobj, objid): - apiobj.add_placex(place_id=1, osm_type='N', osm_id=55, - class_='place', type='suburb') - - assert apiobj.api.lookup(objid, napi.LookupDetails()) is None + result = apiobj.api.lookup((napi.OsmID('W', 1), + napi.OsmID('W', 4), + napi.OsmID('W', 9928)), napi.LookupDetails()) -@pytest.mark.parametrize('gtype', (napi.GeometryFormat.KML, - napi.GeometryFormat.SVG, - napi.GeometryFormat.TEXT)) -def test_lookup_unsupported_geometry(apiobj, gtype): - apiobj.add_placex(place_id=332) + assert len(result) == 2 - with pytest.raises(ValueError): - apiobj.api.lookup(napi.PlaceID(332), - napi.LookupDetails(geometry_output=gtype)) + assert set(r.place_id for r in result) == {332, 4924} diff --git a/test/python/api/test_server_glue_v1.py b/test/python/api/test_server_glue_v1.py index a374bdcf..c0ca69dd 100644 --- a/test/python/api/test_server_glue_v1.py +++ b/test/python/api/test_server_glue_v1.py @@ -335,7 +335,7 @@ class TestDetailsEndpoint: self.lookup_args.extend(args[1:]) return self.result - monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup) + monkeypatch.setattr(napi.NominatimAPIAsync, 'details', _lookup) @pytest.mark.asyncio @@ -384,3 +384,63 @@ class TestDetailsEndpoint: with pytest.raises(FakeError, match='^404 -- .*found'): await glue.details_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a) + + +# lookup_endpoint() + +class TestLookupEndpoint: + + @pytest.fixture(autouse=True) + def patch_lookup_func(self, monkeypatch): + self.results = [napi.SearchResult(napi.SourceTable.PLACEX, + ('place', 'thing'), + napi.Point(1.0, 2.0))] + async def _lookup(*args, **kwargs): + return napi.SearchResults(self.results) + + monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup) + + + @pytest.mark.asyncio + async def test_lookup_no_params(self): + a = FakeAdaptor() + a.params['format'] = 'json' + + res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a) + + assert res.output == '[]' + + + @pytest.mark.asyncio + @pytest.mark.parametrize('param', ['w', 'bad', '']) + async def test_lookup_bad_params(self, param): + a = FakeAdaptor() + a.params['format'] = 'json' + a.params['osm_ids'] = f'W34,{param},N33333' + + res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a) + + assert len(json.loads(res.output)) == 1 + + + @pytest.mark.asyncio + @pytest.mark.parametrize('param', ['p234234', '4563']) + async def test_lookup_bad_osm_type(self, param): + a = FakeAdaptor() + a.params['format'] = 'json' + a.params['osm_ids'] = f'W34,{param},N33333' + + res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a) + + assert len(json.loads(res.output)) == 1 + + + @pytest.mark.asyncio + async def test_lookup_working(self): + a = FakeAdaptor() + a.params['format'] = 'json' + a.params['osm_ids'] = 'N23,W34' + + res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a) + + assert len(json.loads(res.output)) == 1 diff --git a/test/python/cli/test_cmd_api.py b/test/python/cli/test_cmd_api.py index cff83cef..e8c447aa 100644 --- a/test/python/cli/test_cmd_api.py +++ b/test/python/cli/test_cmd_api.py @@ -23,8 +23,7 @@ def test_no_api_without_phpcgi(endpoint): @pytest.mark.parametrize("params", [('search', '--query', 'new'), - ('search', '--city', 'Berlin'), - ('lookup', '--id', 'N1')]) + ('search', '--city', 'Berlin')]) class TestCliApiCallPhp: @pytest.fixture(autouse=True) @@ -81,7 +80,7 @@ class TestCliDetailsCall: result = napi.DetailedResult(napi.SourceTable.PLACEX, ('place', 'thing'), napi.Point(1.0, -3.0)) - monkeypatch.setattr(napi.NominatimAPI, 'lookup', + monkeypatch.setattr(napi.NominatimAPI, 'details', lambda *args: result) @pytest.mark.parametrize("params", [('--node', '1'), @@ -156,32 +155,49 @@ class TestCliReverseCall: assert out['name'] == 'Nom' -QUERY_PARAMS = { - 'search': ('--query', 'somewhere'), - 'reverse': ('--lat', '20', '--lon', '30'), - 'lookup': ('--id', 'R345345'), - 'details': ('--node', '324') -} +class TestCliLookupCall: + + @pytest.fixture(autouse=True) + def setup_lookup_mock(self, monkeypatch): + result = napi.SearchResult(napi.SourceTable.PLACEX, ('place', 'thing'), + napi.Point(1.0, -3.0), + names={'name':'Name', 'name:fr': 'Nom'}, + extratags={'extra':'Extra'}) + + monkeypatch.setattr(napi.NominatimAPI, 'lookup', + lambda *args: napi.SearchResults([result])) + + def test_lookup_simple(self, cli_call, tmp_path, capsys): + result = cli_call('lookup', '--project-dir', str(tmp_path), + '--id', 'N34') + + assert result == 0 + + out = json.loads(capsys.readouterr().out) + assert len(out) == 1 + assert out[0]['name'] == 'Name' + assert 'address' not in out[0] + assert 'extratags' not in out[0] + assert 'namedetails' not in out[0] + -@pytest.mark.parametrize("endpoint", (('search', 'lookup'))) class TestCliApiCommonParameters: @pytest.fixture(autouse=True) - def setup_website_dir(self, cli_call, project_env, endpoint): - self.endpoint = endpoint + def setup_website_dir(self, cli_call, project_env): self.cli_call = cli_call self.project_dir = project_env.project_dir (self.project_dir / 'website').mkdir() def expect_param(self, param, expected): - (self.project_dir / 'website' / (self.endpoint + '.php')).write_text(f"""