diff --git a/docs/index.rst b/docs/index.rst index c938b9836..dab138018 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -218,6 +218,14 @@ HERE .. automethod:: __init__ +HEREv7 +------ + +.. autoclass:: geopy.geocoders.HereV7 + :members: + + .. automethod:: __init__ + IGNFrance --------- diff --git a/geopy/geocoders/here.py b/geopy/geocoders/here.py index 8951b96f3..6bdfbe60b 100644 --- a/geopy/geocoders/here.py +++ b/geopy/geocoders/here.py @@ -23,6 +23,10 @@ class Here(Geocoder): Documentation at: https://developer.here.com/documentation/geocoder/ + + .. attention:: + This class uses a v6 API which is in maintenance mode. + Consider using the newer :class:`.HereV7` class. """ structured_query_params = { diff --git a/geopy/geocoders/herev7.py b/geopy/geocoders/herev7.py index a7ac4df76..ec23d461a 100644 --- a/geopy/geocoders/herev7.py +++ b/geopy/geocoders/herev7.py @@ -1,15 +1,10 @@ +import json from functools import partial from urllib.parse import urlencode -from geopy.exc import ( - ConfigurationError, - GeocoderAuthenticationFailure, - GeocoderInsufficientPrivileges, - GeocoderQuotaExceeded, - GeocoderServiceError, - GeocoderUnavailable, -) -from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder +from geopy.adapters import AdapterHTTPError +from geopy.exc import GeocoderQueryError, GeocoderServiceError +from geopy.geocoders.base import DEFAULT_SENTINEL, ERROR_CODE_MAP, Geocoder from geopy.location import Location from geopy.util import logger @@ -24,20 +19,17 @@ class HereV7(Geocoder): Terms of Service at: https://legal.here.com/en-gb/terms - - ..attention:: - If you need to use the v6 API, use :class: `.HERE` instead. """ structured_query_params = { + 'country', + 'state', + 'county', + 'city', + 'district', 'street', 'houseNumber', 'postalCode', - 'city', - 'district', - 'county', - 'state', - 'country' } geocode_path = '/v1/geocode' @@ -45,8 +37,8 @@ class HereV7(Geocoder): def __init__( self, + apikey, *, - apikey=None, scheme=None, timeout=DEFAULT_SENTINEL, proxies=DEFAULT_SENTINEL, @@ -57,8 +49,8 @@ def __init__( """ :param str apikey: Should be a valid HERE Maps apikey. - More authentication details are available at - https://developer.here.com/authenticationpage. + A project can be created at + https://developer.here.com/projects. :param str scheme: See :attr:`geopy.geocoders.options.default_scheme`. @@ -90,10 +82,6 @@ def __init__( domain = "search.hereapi.com" - if not apikey: - raise ConfigurationError( - "HEREv7 geocoder requires authentication, `apikey` must be set" - ) self.apikey = apikey self.api = "%s://geocode.%s%s" % (self.scheme, domain, self.geocode_path) self.reverse_api = ( @@ -102,110 +90,94 @@ def __init__( def geocode( self, - query, + query=None, *, components=None, at=None, - country=None, + countries=None, language=None, + limit=None, exactly_one=True, - maxresults=None, timeout=DEFAULT_SENTINEL ): """ Return a location point by address. - :param query: The address or query you wish to geocode. + :param str query: The address or query you wish to geocode. Optional, + if ``components`` param is set. - For a structured query, provide a dictionary whose keys are one of: - `street`, `houseNumber`, `postalCode`, `city`, `district` - `county`, `state`, `country`. - - You can specify a free-text query with conditional parameters - by specifying a string in this param and a dict in the components - parameter. - - :param dict components: Components to generate a qualified query. - - Provide a dictionary whose keys are one of: `street`, `houseNumber`, - `postalCode`, `city`, `district`, `county`, `state`, `country`. + :param dict components: A structured query. Can be used along with + the free-text ``query``. Should be a dictionary whose keys + are one of: + `country`, `state`, `county`, `city`, `district`, `street`, + `houseNumber`, `postalCode`. :param at: The center of the search context. - :type at: :class:`geopy.point.Point`, list or tuple of ``(latitude, longitude)``, or string as ``"%(latitude)s, %(longitude)s"``. - :type circle: list or tuple of 2 items: one :class:`geopy.point.Point` or - ``(latitude, longitude)`` or ``"%(latitude)s, %(longitude)s"`` and a numeric - value representing the radius of the circle. - - Only one of either circle, bbox or country can be provided. - - :param country: A list of country codes specified in `ISO 3166-1 alpha-3` format. + :param list countries: A list of country codes specified in + `ISO 3166-1 alpha-3 `_ + format, e.g. ``['USA', 'CAN']``. This is a hard filter. - Only one of either country, circle or bbox can be provided. - - :param bool exactly_one: Return one result or a list of results, if - available. + :param str language: Affects the language of the response, + must be a BCP 47 compliant language code, e.g. ``en-US``. - :param int maxresults: Defines the maximum number of items in the + :param int limit: Defines the maximum number of items in the response structure. If not provided and there are multiple results - the HERE API will return 10 results by default. This will be reset + the HERE API will return 20 results by default. This will be reset to one if ``exactly_one`` is True. - :param str language: Affects the language of the response, - must be a RFC 4647 language code, e.g. 'en-US'. - + :param bool exactly_one: Return one result or a list of results, if + available. :param int timeout: Time, in seconds, to wait for the geocoding service to respond before raising a :class:`geopy.exc.GeocoderTimedOut` exception. Set this only if you wish to override, on this call only, the value set during the geocoder's initialization. + + :rtype: ``None``, :class:`geopy.location.Location` or a list of them, if + ``exactly_one=False``. """ - params = {} + params = { + 'apiKey': self.apikey, + } + + if query: + params['q'] = query - def create_structured_query(d): - components = [ + if components: + parts = [ "{}={}".format(key, val) for key, val - in d.items() if key in self.structured_query_params + in components.items() + if key in self.structured_query_params ] - if components: - return ';'.join(components) - else: - return None - - if isinstance(query, dict): - params['qq'] = create_structured_query(query) - else: - params['q'] = query - - if components and isinstance(components, dict): - params['qq'] = create_structured_query(components) - - if country: - if isinstance(country, list): - country_str = ','.join(country) - else: - country_str = country - - params['in'] = 'countryCode:' + country_str + if not parts: + raise GeocoderQueryError("`components` dict must not be empty") + for pair in parts: + if ';' in pair: + raise GeocoderQueryError( + "';' must not be used in values of the structured query. " + "Offending pair: {!r}".format(pair) + ) + params['qq'] = ';'.join(parts) if at: point = self._coerce_point_to_string(at, output_format="%(lat)s,%(lon)s") params['at'] = point - if maxresults: - params['limit'] = maxresults - - if exactly_one: - params['limit'] = 1 + if countries: + params['in'] = 'countryCode:' + ','.join(countries) if language: params['lang'] = language - params['apiKey'] = self.apikey + if limit: + params['limit'] = limit + if exactly_one: + params['limit'] = 1 url = "?".join((self.api, urlencode(params))) logger.debug("%s.geocode: %s", self.__class__.__name__, url) @@ -216,9 +188,9 @@ def reverse( self, query, *, - exactly_one=True, - maxresults=None, language=None, + limit=None, + exactly_one=True, timeout=DEFAULT_SENTINEL ): """ @@ -229,16 +201,14 @@ def reverse( :type query: :class:`geopy.point.Point`, list or tuple of ``(latitude, longitude)``, or string as ``"%(latitude)s, %(longitude)s"``. - :param bool exactly_one: Return one result or a list of results, if - available. + :param str language: Affects the language of the response, + must be a BCP 47 compliant language code, e.g. ``en-US``. - :param int maxresults: Defines the maximum number of items in the - response structure. If not provided and there are multiple results - the HERE API will return 10 results by default. This will be reset - to one if ``exactly_one`` is True. + :param int limit: Maximum number of results to be returned. + This will be reset to one if ``exactly_one`` is True. - :param str language: Affects the language of the response, - must be a RFC 4647 language code, e.g. 'en-US'. + :param bool exactly_one: Return one result or a list of results, if + available. :param int timeout: Time, in seconds, to wait for the geocoding service to respond before raising a :class:`geopy.exc.GeocoderTimedOut` @@ -248,48 +218,27 @@ def reverse( :rtype: ``None``, :class:`geopy.location.Location` or a list of them, if ``exactly_one=False``. """ - point = self._coerce_point_to_string(query, output_format="%(lat)s,%(lon)s") params = { - 'at': point, - 'apiKey': self.apikey + 'at': self._coerce_point_to_string(query, output_format="%(lat)s,%(lon)s"), + 'apiKey': self.apikey, } - if maxresults: - params['limit'] = min(maxresults, 100) - if exactly_one: - params['limit'] = 1 if language: params['lang'] = language + if limit: + params['limit'] = limit + if exactly_one: + params['limit'] = 1 + url = "%s?%s" % (self.reverse_api, urlencode(params)) logger.debug("%s.reverse: %s", self.__class__.__name__, url) callback = partial(self._parse_json, exactly_one=exactly_one) return self._call_geocoder(url, callback, timeout=timeout) def _parse_json(self, doc, exactly_one=True): - """ - Parse a location name, latitude, and longitude from an JSON response. - """ - status_code = doc.get("statusCode", 200) - if status_code != 200: - err = doc.get('title') or doc.get('error_description') - if status_code == 401: - raise GeocoderAuthenticationFailure(err) - elif status_code == 403: - raise GeocoderInsufficientPrivileges(err) - elif status_code == 429: - raise GeocoderQuotaExceeded(err) - elif status_code == 503: - raise GeocoderUnavailable(err) - else: - raise GeocoderServiceError(err) - - try: - resources = doc['items'] - except IndexError: - resources = None - + resources = doc['items'] if not resources: return None @@ -297,8 +246,6 @@ def parse_resource(resource): """ Parse each return object. """ - # stripchars = ", \n" - location = resource['title'] position = resource['position'] @@ -310,3 +257,19 @@ def parse_resource(resource): return parse_resource(resources[0]) else: return [parse_resource(resource) for resource in resources] + + def _geocoder_exception_handler(self, error): + if not isinstance(error, AdapterHTTPError): + return + if error.status_code is None or error.text is None: + return + try: + body = json.loads(error.text) + except ValueError: + message = error.text + else: + # `title`: https://developer.here.com/documentation/geocoding-search-api/api-reference-swagger.html # noqa + # `error_description`: returned for queries without apiKey. + message = body.get('title') or body.get('error_description') or error.text + exc_cls = ERROR_CODE_MAP.get(error.status_code, GeocoderServiceError) + raise exc_cls(message) from error diff --git a/test/geocoders/herev7.py b/test/geocoders/herev7.py index 32ab6fc4b..e20c86bd7 100644 --- a/test/geocoders/herev7.py +++ b/test/geocoders/herev7.py @@ -1,35 +1,13 @@ -import warnings - -import pytest - -from geopy import exc from geopy.geocoders import HereV7 from geopy.point import Point from test.geocoders.util import BaseTestGeocoder, env -class TestUnitHere: - - def test_user_agent_custom(self): - geocoder = HereV7( - apikey='DUMMYKEY1234', - user_agent='my_user_agent/1.0' - ) - assert geocoder.headers['User-Agent'] == 'my_user_agent/1.0' - - def test_error_with_no_keys(self): - with pytest.raises(exc.ConfigurationError): - HereV7() - - def test_no_warning_with_apikey(self): - with warnings.catch_warnings(record=True) as w: - HereV7( - apikey='DUMMYKEY1234', - ) - assert len(w) == 0 - +class TestHereV7(BaseTestGeocoder): -class BaseTestHere(BaseTestGeocoder): + @classmethod + def make_geocoder(cls, **kwargs): + return HereV7(env['HERE_APIKEY'], **kwargs) async def test_geocode_empty_result(self): await self.geocode_run( @@ -44,17 +22,30 @@ async def test_geocode(self): {"latitude": 41.890, "longitude": -87.624} ) + async def test_geocode_query_and_components(self): + query = "435 north michigan ave" + components = { + "city": "chicago", + "state": "il", + "postalCode": 60611, + "country": "usa", + } + await self.geocode_run( + {"query": query, "components": components}, + {"latitude": 41.890, "longitude": -87.624} + ) + async def test_geocode_structured(self): - query = { + components = { "street": "north michigan ave", - "housenumber": "435", + "houseNumber": "435", "city": "chicago", "state": "il", - "postalcode": 60611, - "country": "usa" + "postalCode": 60611, + "country": "usa", } await self.geocode_run( - {"query": query}, + {"components": components}, {"latitude": 41.890, "longitude": -87.624} ) @@ -65,7 +56,7 @@ async def test_geocode_unicode_name(self): {"latitude": 48.85718, "longitude": 2.34141} ) - async def test_search_context(self): + async def test_geocode_at(self): await self.geocode_run( { "query": "moscow", # Idaho USA @@ -74,7 +65,16 @@ async def test_search_context(self): {"latitude": 46.7323875, "longitude": -117.0001651}, ) - async def test_geocode_with_language_de(self): + async def test_geocode_countries(self): + await self.geocode_run( + { + "query": "moscow", # Idaho USA + "countries": ["USA", "CAN"], + }, + {"latitude": 46.7323875, "longitude": -117.0001651}, + ) + + async def test_geocode_language(self): address_string = "435 north michigan ave, chicago il 60611 usa" res = await self.geocode_run( {"query": address_string, "language": "de-DE"}, @@ -82,56 +82,49 @@ async def test_geocode_with_language_de(self): ) assert "Vereinigte Staaten" in res.address - async def test_geocode_with_language_en(self): - address_string = "435 north michigan ave, chicago il 60611 usa" res = await self.geocode_run( {"query": address_string, "language": "en-US"}, {} ) assert "United States" in res.address + async def test_geocode_limit(self): + res = await self.geocode_run( + { + "query": "maple street", + "limit": 5, + "exactly_one": False + }, + {} + ) + assert len(res) == 5 + async def test_reverse(self): await self.reverse_run( {"query": Point(40.753898, -73.985071)}, {"latitude": 40.753898, "longitude": -73.985071} ) - async def test_reverse_with_language_de(self): + async def test_reverse_language(self): res = await self.reverse_run( {"query": Point(40.753898, -73.985071), "language": "de-DE"}, {} ) assert "Vereinigte Staaten" in res.address - async def test_reverse_with_language_en(self): res = await self.reverse_run( {"query": Point(40.753898, -73.985071), "language": "en-US"}, {} ) assert "United States" in res.address - async def test_reverse_with_maxresults_5(self): + async def test_reverse_limit(self): res = await self.reverse_run( { "query": Point(40.753898, -73.985071), - "maxresults": 5, + "limit": 5, "exactly_one": False }, {} ) assert len(res) == 5 - - -@pytest.mark.skipif( - not bool(env.get('HERE_APIKEY')), - reason="No HERE_APIKEY env variable set" -) -class TestHereApiKey(BaseTestHere): - - @classmethod - def make_geocoder(cls, **kwargs): - return HereV7( - apikey=env['HERE_APIKEY'], - timeout=10, - **kwargs - )