""" Geocoder module. """ from datetime import datetime from decimal import Decimal import collections import os import six import requests import backoff def backoff_max_time(): return int(os.environ.get('BACKOFF_MAX_TIME', '120')) class OpenCageGeocodeError(Exception): """Base class for all errors/exceptions that can happen when geocoding.""" pass class InvalidInputError(OpenCageGeocodeError): """ There was a problem with the input you provided. :var bad_value: The value that caused the problem """ def __init__(self, bad_value): super(InvalidInputError, self).__init__() self.bad_value = bad_value def __unicode__(self): return "Input must be a unicode string, not "+repr(self.bad_value)[:100] __str__ = __unicode__ class UnknownError(OpenCageGeocodeError): """There was a problem with the OpenCage server.""" pass class RateLimitExceededError(OpenCageGeocodeError): """ Exception raised when account has exceeded it's limit. :var datetime reset_time: When your account limit will be reset. :var int reset_to: What your account will be reset to. """ def __init__(self, reset_time, reset_to): """Constructor.""" super(RateLimitExceededError, self).__init__() self.reset_time = reset_time self.reset_to = reset_to def __unicode__(self): """Convert exception to a string.""" return "Your rate limit has expired. It will reset to {0} on {1}".format(self.reset_to, self.reset_time.isoformat()) __str__ = __unicode__ class NotAuthorizedError(OpenCageGeocodeError): """ Exception raised when an unautorized API key is used. """ def __unicode__(self): """Convert exception to a string.""" return "Your API key is not authorized. You may have entered it incorrectly." __str__ = __unicode__ class ForbiddenError(OpenCageGeocodeError): """ Exception raised when a blocked or suspended API key is used. """ def __unicode__(self): """Convert exception to a string.""" return "Your API key has been blocked or suspended." __str__ = __unicode__ class OpenCageGeocode(object): """ Geocoder object. Initialize it with your API key: >>> geocoder = OpenCageGeocode('your-key-here') Query: >>> geocoder.geocode("London") Reverse geocode a latitude & longitude into a point: >>> geocoder.reverse_geocode(51.5104, -0.1021) """ url = 'https://api.opencagedata.com/geocode/v1/json' key = '' def __init__(self, key): """Constructor.""" self.key = key def geocode(self, query, **kwargs): """ Given a string to search for, return the results from OpenCage's Geocoder. :param string query: String to search for :returns: Dict results :raises InvalidInputError: if the query string is not a unicode string :raises RateLimitExceededError: if you have exceeded the number of queries you can make. Exception says when you can try again :raises UnknownError: if something goes wrong with the OpenCage API """ if six.PY2: # py3 doesn't have unicode() function, and instead we check the text_type later try: query = unicode(query) except UnicodeDecodeError: raise InvalidInputError(bad_value=query) if not isinstance(query, six.text_type): raise InvalidInputError(bad_value=query) data = { 'q': query, 'key': self.key } # Add user parameters data.update(kwargs) response = self._opencage_request(data) return floatify_latlng(response['results']) def reverse_geocode(self, lat, lng, **kwargs): """ Given a latitude & longitude, return an address for that point from OpenCage's Geocoder. :param lat: Latitude :param lng: Longitude :return: Results from OpenCageData :rtype: dict :raises RateLimitExceededError: if you have exceeded the number of queries you can make. Exception says when you can try again :raises UnknownError: if something goes wrong with the OpenCage API """ return self.geocode(_query_for_reverse_geocoding(lat, lng), **kwargs) @backoff.on_exception( backoff.expo, (UnknownError, requests.exceptions.RequestException), max_tries=5, max_time=backoff_max_time) def _opencage_request(self, params): response = requests.get(self.url, params=params) if (response.status_code == 401): raise NotAuthorizedError() if (response.status_code == 403): raise ForbiddenError() if (response.status_code == 402 or response.status_code == 429): # Rate limit exceeded reset_time = datetime.utcfromtimestamp(response.json()['rate']['reset']) raise RateLimitExceededError(reset_to=int(response.json()['rate']['limit']), reset_time=reset_time) elif response.status_code == 500: raise UnknownError("500 status code from API") try: response_json = response.json() except ValueError: raise UnknownError("Non-JSON result from server") if 'results' not in response_json: raise UnknownError("JSON from API doesn't have a 'results' key") return response_json def _query_for_reverse_geocoding(lat, lng): """ Given a lat & lng, what's the string search query. If the API changes, change this function. Only for internal use. """ # have to do some stupid f/Decimal/str stuff to (a) ensure we get as much # decimal places as the user already specified and (b) to ensure we don't # get e-5 stuff return "{0:f},{1:f}".format(Decimal(str(lat)), Decimal(str(lng))) def float_if_float(float_string): """ Given a float string, returns the float value. On value error returns the original string. """ try: float_val = float(float_string) return float_val except ValueError: return float_string def floatify_latlng(input_value): """ Work around a JSON dict with string, not float, lat/lngs. Given anything (list/dict/etc) it will return that thing again, *but* any dict (at any level) that has only 2 elements lat & lng, will be replaced with the lat & lng turned into floats. If the API returns the lat/lng as strings, and not numbers, then this function will 'clean them up' to be floats. """ if isinstance(input_value, collections.Mapping): if len(input_value) == 2 and sorted(input_value.keys()) == ['lat', 'lng']: # This dict has only 2 keys 'lat' & 'lon' return {'lat': float_if_float(input_value["lat"]), 'lng': float_if_float(input_value["lng"])} else: return dict((key, floatify_latlng(value)) for key, value in input_value.items()) elif isinstance(input_value, collections.MutableSequence): return [floatify_latlng(x) for x in input_value] else: return input_value