New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add geocodio #468
Merged
Merged
Add geocodio #468
Changes from 8 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
5bac30d
Implement geocode for Geocodio class
duckontheweb a621924
Support geocoding from address components
duckontheweb 6fec82b
Add reverse geocoding support
duckontheweb f3b5bc0
Update docstrings
duckontheweb df3ff4f
Fix trailing comma for Python 3.5
duckontheweb dbc5bf3
Make api_key required
duckontheweb 24b45cb
Remove unnecessary kwarg popping
duckontheweb 5deb061
Static method -> instance method + fix lint error
duckontheweb f557d06
Merge branch 'master' into add-geocodio
KostyaEsmukov File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,240 @@ | ||
import json | ||
from functools import partial | ||
from urllib.parse import urlencode | ||
|
||
from geopy.exc import 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, | ||
*, | ||
scheme=None, | ||
timeout=DEFAULT_SENTINEL, | ||
proxies=DEFAULT_SENTINEL, | ||
user_agent=None, | ||
ssl_context=DEFAULT_SENTINEL, | ||
adapter_factory=None | ||
): | ||
""" | ||
:param str api_key: | ||
A valid Geocod.io API key. (https://dash.geocod.io/apikey/create) | ||
|
||
:param str scheme: | ||
See :attr:`geopy.geocoders.options.default_scheme`. | ||
|
||
: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`. | ||
""" | ||
super().__init__( | ||
scheme=scheme, | ||
timeout=timeout, | ||
proxies=proxies, | ||
user_agent=user_agent, | ||
ssl_context=ssl_context, | ||
adapter_factory=adapter_factory, | ||
) | ||
self.api_key = api_key | ||
|
||
def geocode( | ||
self, | ||
query=None, | ||
*, | ||
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: 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: The maximum number of matches to return. This will be reset | ||
to 1 if ``exactly_one`` is ``True``. | ||
|
||
: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. | ||
|
||
: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 city: The city of the address to geocode. If providing this argument | ||
you must *not* provide a ``query`` argument. | ||
|
||
:param str state: The state of the address to geocode. If providing this argument | ||
you must *not* provide a ``query`` argument. | ||
|
||
: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 country: The country of the address to geocode. If providing this | ||
argument you must *not* provide a ``query`` argument. | ||
|
||
: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)): | ||
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, | ||
street=street, | ||
city=city, | ||
state=state, | ||
postal_code=postal_code, | ||
country=country, | ||
limit=limit | ||
) | ||
params = { | ||
k: v for k, v in params.items() if v is not None | ||
} | ||
|
||
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) | ||
|
||
def reverse( | ||
self, | ||
query, | ||
*, | ||
exactly_one=True, | ||
timeout=DEFAULT_SENTINEL, | ||
limit=None | ||
): | ||
"""Return an address by location point. | ||
|
||
:param str query: The coordinates for which you wish to obtain the | ||
closest human-readable addresses | ||
|
||
: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. | ||
|
||
:param int limit: The maximum number of matches to return. This will be reset | ||
to 1 if ``exactly_one`` is ``True``. | ||
|
||
:rtype: ``None``, :class:`geopy.location.Location` or a list of them, if | ||
``exactly_one=False``. | ||
""" | ||
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) | ||
|
||
def _parse_json(self, 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.' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import pytest | ||
|
||
from geopy import exc | ||
from geopy.geocoders import Geocodio | ||
from geopy.point import Point | ||
from test.geocoders.util import BaseTestGeocoder, env | ||
|
||
|
||
class TestGeocodio(BaseTestGeocoder): | ||
|
||
@classmethod | ||
def make_geocoder(cls, **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') | ||
assert geocoder.headers['User-Agent'] == 'my_user_agent/1.0' | ||
|
||
async def test_error_with_no_api_key(self): | ||
with pytest.raises(TypeError): | ||
Geocodio() | ||
|
||
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( | ||
{"query": ''}, | ||
{}, | ||
expect_failure=True, | ||
) | ||
|
||
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() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason why these conditions must be checked on the client-side?
(I'm okay with keeping these unless they might change in future).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the only argument would be to save making an invalid request, which might count against rate limits or account request limits.