From 5bac30dcc3847d09f64330d08f620caefaeb8aeb Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Mon, 1 Feb 2021 20:01:35 -0500 Subject: [PATCH 1/8] Implement geocode for Geocodio class --- geopy/geocoders/__init__.py | 3 + geopy/geocoders/geocodio.py | 145 ++++++++++++++++++++++++++++++++++++ test/geocoders/geocodio.py | 38 ++++++++++ 3 files changed, 186 insertions(+) create mode 100644 geopy/geocoders/geocodio.py create mode 100644 test/geocoders/geocodio.py diff --git a/geopy/geocoders/__init__.py b/geopy/geocoders/__init__.py index 3326f7a9e..7d5d4cf95 100644 --- a/geopy/geocoders/__init__.py +++ b/geopy/geocoders/__init__.py @@ -196,6 +196,7 @@ "DataBC", "GeocodeEarth", "GeocodeFarm", + "Geocodio", "GeoNames", "GoogleV3", "Geolake", @@ -228,6 +229,7 @@ from geopy.geocoders.databc import DataBC from geopy.geocoders.geocodeearth import GeocodeEarth from geopy.geocoders.geocodefarm import GeocodeFarm +from geopy.geocoders.geocodio import Geocodio from geopy.geocoders.geolake import Geolake from geopy.geocoders.geonames import GeoNames from geopy.geocoders.googlev3 import GoogleV3 @@ -258,6 +260,7 @@ "databc": DataBC, "geocodeearth": GeocodeEarth, "geocodefarm": GeocodeFarm, + "geocodio": Geocodio, "geonames": GeoNames, "google": GoogleV3, "googlev3": GoogleV3, diff --git a/geopy/geocoders/geocodio.py b/geopy/geocoders/geocodio.py new file mode 100644 index 000000000..d7c1e64b2 --- /dev/null +++ b/geopy/geocoders/geocodio.py @@ -0,0 +1,145 @@ +import json +from functools import partial +from urllib.parse import urlencode + +from geopy.exc import ConfigurationError, GeocoderQueryError, GeocoderQuotaExceeded +from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder +from geopy.location import Location +from geopy.util import logger + +__all__ = ("Geocodio", ) + + +class Geocodio(Geocoder): + """Geocoder using the Geocod.io API. + + Documentation at: + https://www.geocod.io/docs/ + + Pricing details: + https://www.geocod.io/pricing/ + + """ + + domain = 'api.geocod.io' + geocode_path = '/v1.6/geocode' + reverse_path = '/v1.6/reverse' + + def __init__( + self, + api_key=None, + *, + scheme=None, + timeout=DEFAULT_SENTINEL, + proxies=DEFAULT_SENTINEL, + user_agent=None, + ssl_context=DEFAULT_SENTINEL, + adapter_factory=None, + ): + """ + + :param str api_key: + See :attr:`geopy.geocoders.options.default_scheme`. + + :param str client_id: If using premier, the account client id. + + :param str secret_key: If using premier, the account secret key. + + :param int timeout: + See :attr:`geopy.geocoders.options.default_timeout`. + + :param dict proxies: + See :attr:`geopy.geocoders.options.default_proxies`. + + :param str user_agent: + See :attr:`geopy.geocoders.options.default_user_agent`. + + :type ssl_context: :class:`ssl.SSLContext` + :param ssl_context: + See :attr:`geopy.geocoders.options.default_ssl_context`. + + :param callable adapter_factory: + See :attr:`geopy.geocoders.options.default_adapter_factory`. + + .. versionadded:: 2.0 + """ + super().__init__( + scheme=scheme, + timeout=timeout, + proxies=proxies, + user_agent=user_agent, + ssl_context=ssl_context, + adapter_factory=adapter_factory, + ) + if api_key is None: + raise ConfigurationError('Must provide an api_key.') + self.api_key = api_key + + def geocode( + self, + query=None, + *, + limit=None, + exactly_one=True, + timeout=DEFAULT_SENTINEL, + ): + api = '%s://%s%s' % (self.scheme, self.domain, self.geocode_path) + + params = dict( + q=query, + api_key=self.api_key + ) + if limit is not None: + params['limit'] = limit + + url = "?".join((api, urlencode(params))) + + logger.debug("%s.geocode: %s", self.__class__.__name__, url) + callback = partial(self._parse_json, exactly_one=exactly_one) + return self._call_geocoder(url, callback, timeout=timeout) + + @staticmethod + def _parse_json(page, exactly_one=True): + """Returns location, (latitude, longitude) from json feed.""" + + places = page.get('results', []) + + def parse_place(place): + """Get the location, lat, lng from a single json place.""" + location = place.get('formatted_address') + latitude = place['location']['lat'] + longitude = place['location']['lng'] + return Location(location, (latitude, longitude), place) + + if exactly_one: + return parse_place(places[0]) + else: + return [parse_place(place) for place in places] + + def _geocoder_exception_handler(self, error): + """Custom exception handling for invalid queries and exceeded quotas. + + Geocod.io returns a ``422`` status code for invalid queries, which is not mapped + in :const:`~geopy.geocoders.base.ERROR_CODE_MAP`. The service also returns a + ``403`` status code for exceeded quotas instead of the ``429`` code mapped in + :const:`~geopy.geocoders.base.ERROR_CODE_MAP` + """ + if error.status_code == 422: + error_message = self._get_error_message(error) + raise GeocoderQueryError(error_message) + if error.status_code == 403: + error_message = self._get_error_message(error) + quota_exceeded_snippet = "You can't make this request as it is " \ + "above your daily maximum." + if quota_exceeded_snippet in error_message: + raise GeocoderQuotaExceeded(error_message) + + @staticmethod + def _get_error_message(error): + """Try to extract an error message from the 'error' property of a JSON response. + """ + try: + error_message = json.loads(error.text).get('error') + except json.JSONDecodeError: + error_message = None + return error_message or 'There was an unknown issue with the query.' diff --git a/test/geocoders/geocodio.py b/test/geocoders/geocodio.py new file mode 100644 index 000000000..f39f6b4e1 --- /dev/null +++ b/test/geocoders/geocodio.py @@ -0,0 +1,38 @@ +import pytest + +from geopy import exc +from geopy.geocoders import Geocodio +from test.geocoders.util import BaseTestGeocoder, env + + +class TestGeocodio(BaseTestGeocoder): + + @classmethod + def make_geocoder(cls, **kwargs): + api_key = kwargs.pop('api_key') if 'api_key' in kwargs else env['GEOCODIO_KEY'] + return Geocodio(api_key=api_key, **kwargs) + + async def test_user_agent_custom(self): + geocoder = self.make_geocoder(user_agent='my_user_agent/1.0') + assert geocoder.headers['User-Agent'] == 'my_user_agent/1.0' + + async def test_error_with_no_api_key(self): + with pytest.raises(exc.ConfigurationError): + self.make_geocoder(api_key=None) + + async def test_geocode(self): + await self.geocode_run( + {"query": "435 north michigan ave, chicago il 60611 usa"}, + {"latitude": 41.89037, "longitude": -87.623192}, + ) + + async def test_zero_results(self): + with pytest.raises(exc.GeocoderQueryError) as excinfo: + await self.geocode_run( + {"query": ''}, + {}, + expect_failure=True, + ) + + assert str(excinfo.value) == 'Could not geocode address. ' \ + 'Postal code or city required.' From a6219242892078eba78b52a358295274591d090e Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Mon, 1 Feb 2021 20:25:22 -0500 Subject: [PATCH 2/8] Support geocoding from address components --- geopy/geocoders/geocodio.py | 59 ++++++++++++++++++++++++++++++------- test/geocoders/geocodio.py | 32 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/geopy/geocoders/geocodio.py b/geopy/geocoders/geocodio.py index d7c1e64b2..d1af5bb48 100644 --- a/geopy/geocoders/geocodio.py +++ b/geopy/geocoders/geocodio.py @@ -37,13 +37,11 @@ def __init__( adapter_factory=None, ): """ - :param str api_key: - See :attr:`geopy.geocoders.options.default_scheme`. - - :param str client_id: If using premier, the account client id. + A valid Geocod.io API key. (https://dash.geocod.io/apikey/create) - :param str secret_key: If using premier, the account secret key. + :param str scheme: + See :attr:`geopy.geocoders.options.default_scheme`. :param int timeout: See :attr:`geopy.geocoders.options.default_timeout`. @@ -60,8 +58,6 @@ def __init__( :param callable adapter_factory: See :attr:`geopy.geocoders.options.default_adapter_factory`. - - .. versionadded:: 2.0 """ super().__init__( scheme=scheme, @@ -82,15 +78,58 @@ def geocode( limit=None, exactly_one=True, timeout=DEFAULT_SENTINEL, + street=None, + city=None, + state=None, + postal_code=None, + country=None, ): + """Return a location point by address. You may either provide a single address + string as a ``query`` argument or individual address components using the + ``street``, ``city``, ``state``, ``postal_code``, and ``country`` arguments. + + :param str query: + + :param int limit: + + :param bool exactly_one: + + :param int timeout: + + :param str street: + + :param str city: + + :param str state: + + :param str postal_code: + + :param str country: + """ + if query is not None and \ + any(p is not None for p in (city, state, postal_code, country)): + raise GeocoderQueryError('Address component must not be provided if ' + 'query argument is used.') + if street is not None and \ + not any(p is not None for p in (city, state, postal_code)): + raise GeocoderQueryError('If street is provided must also provide city, ' + 'state, and/or postal_code.') + api = '%s://%s%s' % (self.scheme, self.domain, self.geocode_path) params = dict( + api_key=self.api_key, q=query, - api_key=self.api_key + street=street, + city=city, + state=state, + postal_code=postal_code, + country=country, + limit=limit ) - if limit is not None: - params['limit'] = limit + params = { + k: v for k, v in params.items() if v is not None + } url = "?".join((api, urlencode(params))) diff --git a/test/geocoders/geocodio.py b/test/geocoders/geocodio.py index f39f6b4e1..13b284620 100644 --- a/test/geocoders/geocodio.py +++ b/test/geocoders/geocodio.py @@ -20,12 +20,44 @@ async def test_error_with_no_api_key(self): with pytest.raises(exc.ConfigurationError): self.make_geocoder(api_key=None) + async def test_error_with_query_and_street(self): + with pytest.raises(exc.GeocoderQueryError): + await self.geocode_run( + { + 'query': '435 north michigan ave, chicago il 60611 usa', + 'street': '435 north michigan ave' + }, + {}, + expect_failure=True + ) + + async def test_error_with_only_street(self): + with pytest.raises(exc.GeocoderQueryError): + await self.geocode_run( + { + 'street': '435 north michigan ave' + }, + {}, + expect_failure=True + ) + async def test_geocode(self): await self.geocode_run( {"query": "435 north michigan ave, chicago il 60611 usa"}, {"latitude": 41.89037, "longitude": -87.623192}, ) + async def test_geocode_from_components(self): + await self.geocode_run( + { + "street": "435 north michigan ave", + "city": "chicago", + "state": "IL", + "postal_code": "60611" + }, + {"latitude": 41.89037, "longitude": -87.623192}, + ) + async def test_zero_results(self): with pytest.raises(exc.GeocoderQueryError) as excinfo: await self.geocode_run( From 6fec82bf26c3caf1b24c9088fe4be048868ca55e Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Mon, 1 Feb 2021 20:42:35 -0500 Subject: [PATCH 3/8] Add reverse geocoding support --- geopy/geocoders/geocodio.py | 33 +++++++++++++++++++++++++++++++++ test/geocoders/geocodio.py | 9 +++++++++ 2 files changed, 42 insertions(+) diff --git a/geopy/geocoders/geocodio.py b/geopy/geocoders/geocodio.py index d1af5bb48..6a4a985ba 100644 --- a/geopy/geocoders/geocodio.py +++ b/geopy/geocoders/geocodio.py @@ -137,6 +137,39 @@ def geocode( callback = partial(self._parse_json, exactly_one=exactly_one) return self._call_geocoder(url, callback, timeout=timeout) + def reverse( + self, + query, + *, + exactly_one=True, + timeout=DEFAULT_SENTINEL, + limit=None + ): + """Return an address by location point. + + :param str query: + + :param bool exactly_one: + + :param int timeout: + + :param int limit: + """ + params = { + 'q': self._coerce_point_to_string(query), + 'api_key': self.api_key + } + if limit is not None: + params['limit'] = limit + + api = '%s://%s%s' % (self.scheme, self.domain, self.reverse_path) + + url = "?".join((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) + @staticmethod def _parse_json(page, exactly_one=True): """Returns location, (latitude, longitude) from json feed.""" diff --git a/test/geocoders/geocodio.py b/test/geocoders/geocodio.py index 13b284620..283faa982 100644 --- a/test/geocoders/geocodio.py +++ b/test/geocoders/geocodio.py @@ -2,6 +2,7 @@ from geopy import exc from geopy.geocoders import Geocodio +from geopy.point import Point from test.geocoders.util import BaseTestGeocoder, env @@ -68,3 +69,11 @@ async def test_zero_results(self): assert str(excinfo.value) == 'Could not geocode address. ' \ 'Postal code or city required.' + + async def test_reverse(self): + location = await self.reverse_run( + {"query": Point(40.75376406311989, -73.98489005863667)}, + {"latitude": 40.75376406311989, "longitude": -73.98489005863667}, + skiptest_on_failure=True, # sometimes the result is empty + ) + assert "new york" in location.address.lower() From f3b5bc012e172479af333ebe861e1c2635a7c816 Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Mon, 1 Feb 2021 21:00:17 -0500 Subject: [PATCH 4/8] Update docstrings --- docs/index.rst | 8 ++++++ geopy/geocoders/geocodio.py | 52 +++++++++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 810fef3d8..c22913320 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -179,6 +179,14 @@ GeocodeFarm .. automethod:: __init__ +Geocodio +-------- + +.. autoclass:: geopy.geocoders.Geocodio + :members: + + .. automethod:: __init__ + Geolake -------- diff --git a/geopy/geocoders/geocodio.py b/geopy/geocoders/geocodio.py index 6a4a985ba..223da3519 100644 --- a/geopy/geocoders/geocodio.py +++ b/geopy/geocoders/geocodio.py @@ -88,23 +88,40 @@ def geocode( string as a ``query`` argument or individual address components using the ``street``, ``city``, ``state``, ``postal_code``, and ``country`` arguments. - :param str query: + :param str query: The address or query you wish to geocode. You must either + provide this argument or a valid combination of ``street``, ``city``, + ``state``, and ``postal_code`` and you may not provide those arguments if + also providing ``query``. - :param int limit: + :param int limit: The maximum number of matches to return. This will be reset + to 1 if ``exactly_one`` is ``True``. - :param bool exactly_one: + :param bool exactly_one: Return one result or a list of results, if + available. - :param int timeout: + :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. + + :param str street: The street address to geocode. If providing this argument + you must provide at least one of ``city``, ``state``, or ``postal_code``, and + you must *not* provide a ``query`` argument. - :param str street: + :param str city: The city of the address to geocode. If providing this argument + you must *not* provide a ``query`` argument. - :param str city: + :param str state: The state of the address to geocode. If providing this argument + you must *not* provide a ``query`` argument. - :param str state: + :param str postal_code: The postal code of the address to geocode. If providing + this argument you must *not* provide a ``query`` argument. - :param str postal_code: + :param str country: The country of the address to geocode. If providing this + argument you must *not* provide a ``query`` argument. - :param str country: + :rtype: ``None``, :class:`geopy.location.Location` or a list of them, if + ``exactly_one=False``. """ if query is not None and \ any(p is not None for p in (city, state, postal_code, country)): @@ -147,13 +164,22 @@ def reverse( ): """Return an address by location point. - :param str query: + :param str query: The coordinates for which you wish to obtain the + closest human-readable addresses - :param bool exactly_one: + :param bool exactly_one: Return one result or a list of results, if + available. - :param int timeout: + :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. + + :param int limit: The maximum number of matches to return. This will be reset + to 1 if ``exactly_one`` is ``True``. - :param int limit: + :rtype: ``None``, :class:`geopy.location.Location` or a list of them, if + ``exactly_one=False``. """ params = { 'q': self._coerce_point_to_string(query), From df3ff4fa120fbca6006b7ef27b923c24271c02b5 Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Mon, 1 Feb 2021 21:15:24 -0500 Subject: [PATCH 5/8] Fix trailing comma for Python 3.5 --- geopy/geocoders/geocodio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/geopy/geocoders/geocodio.py b/geopy/geocoders/geocodio.py index 223da3519..ea856dde7 100644 --- a/geopy/geocoders/geocodio.py +++ b/geopy/geocoders/geocodio.py @@ -34,7 +34,7 @@ def __init__( proxies=DEFAULT_SENTINEL, user_agent=None, ssl_context=DEFAULT_SENTINEL, - adapter_factory=None, + adapter_factory=None ): """ :param str api_key: @@ -82,7 +82,7 @@ def geocode( city=None, state=None, postal_code=None, - country=None, + country=None ): """Return a location point by address. You may either provide a single address string as a ``query`` argument or individual address components using the From dbc5bf377cf3d6c42b211447eb31013e6a1115dc Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Thu, 25 Mar 2021 21:08:02 -0400 Subject: [PATCH 6/8] Make api_key required --- geopy/geocoders/geocodio.py | 4 +--- test/geocoders/geocodio.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/geopy/geocoders/geocodio.py b/geopy/geocoders/geocodio.py index ea856dde7..f5f3df3e3 100644 --- a/geopy/geocoders/geocodio.py +++ b/geopy/geocoders/geocodio.py @@ -27,7 +27,7 @@ class Geocodio(Geocoder): def __init__( self, - api_key=None, + api_key, *, scheme=None, timeout=DEFAULT_SENTINEL, @@ -67,8 +67,6 @@ def __init__( ssl_context=ssl_context, adapter_factory=adapter_factory, ) - if api_key is None: - raise ConfigurationError('Must provide an api_key.') self.api_key = api_key def geocode( diff --git a/test/geocoders/geocodio.py b/test/geocoders/geocodio.py index 283faa982..9a52f359e 100644 --- a/test/geocoders/geocodio.py +++ b/test/geocoders/geocodio.py @@ -18,8 +18,8 @@ async def test_user_agent_custom(self): assert geocoder.headers['User-Agent'] == 'my_user_agent/1.0' async def test_error_with_no_api_key(self): - with pytest.raises(exc.ConfigurationError): - self.make_geocoder(api_key=None) + with pytest.raises(TypeError): + Geocodio() async def test_error_with_query_and_street(self): with pytest.raises(exc.GeocoderQueryError): From 24b45cb265fb267f640f23b6a83baa684c57b5c2 Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Thu, 25 Mar 2021 21:12:30 -0400 Subject: [PATCH 7/8] Remove unnecessary kwarg popping --- test/geocoders/geocodio.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/geocoders/geocodio.py b/test/geocoders/geocodio.py index 9a52f359e..fb751cae7 100644 --- a/test/geocoders/geocodio.py +++ b/test/geocoders/geocodio.py @@ -10,8 +10,7 @@ class TestGeocodio(BaseTestGeocoder): @classmethod def make_geocoder(cls, **kwargs): - api_key = kwargs.pop('api_key') if 'api_key' in kwargs else env['GEOCODIO_KEY'] - return Geocodio(api_key=api_key, **kwargs) + return Geocodio(api_key=env['GEOCODIO_KEY'], **kwargs) async def test_user_agent_custom(self): geocoder = self.make_geocoder(user_agent='my_user_agent/1.0') From 5deb061124a933cc9b2d70c6db65e58a8bdf9b7b Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Thu, 25 Mar 2021 21:17:20 -0400 Subject: [PATCH 8/8] Static method -> instance method + fix lint error --- geopy/geocoders/geocodio.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/geopy/geocoders/geocodio.py b/geopy/geocoders/geocodio.py index f5f3df3e3..43c3752c4 100644 --- a/geopy/geocoders/geocodio.py +++ b/geopy/geocoders/geocodio.py @@ -2,7 +2,7 @@ from functools import partial from urllib.parse import urlencode -from geopy.exc import ConfigurationError, GeocoderQueryError, GeocoderQuotaExceeded +from geopy.exc import GeocoderQueryError, GeocoderQuotaExceeded from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder from geopy.location import Location from geopy.util import logger @@ -194,8 +194,7 @@ def reverse( callback = partial(self._parse_json, exactly_one=exactly_one) return self._call_geocoder(url, callback, timeout=timeout) - @staticmethod - def _parse_json(page, exactly_one=True): + def _parse_json(self, page, exactly_one=True): """Returns location, (latitude, longitude) from json feed.""" places = page.get('results', [])