424 lines
14 KiB
Python
424 lines
14 KiB
Python
|
import re
|
||
|
from functools import partial
|
||
|
from urllib.parse import urlencode
|
||
|
|
||
|
from geopy import exc
|
||
|
from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
|
||
|
from geopy.location import Location
|
||
|
from geopy.util import logger
|
||
|
|
||
|
__all__ = ("What3Words", "What3WordsV3")
|
||
|
|
||
|
_MULTIPLE_WORD_RE = re.compile(
|
||
|
r"[^\W\d\_]+\.{1,1}[^\W\d\_]+\.{1,1}[^\W\d\_]+$", re.U
|
||
|
)
|
||
|
|
||
|
|
||
|
def _check_query(query):
|
||
|
"""
|
||
|
Check query validity with regex
|
||
|
"""
|
||
|
if not _MULTIPLE_WORD_RE.match(query):
|
||
|
return False
|
||
|
else:
|
||
|
return True
|
||
|
|
||
|
|
||
|
class What3Words(Geocoder):
|
||
|
"""What3Words geocoder using the legacy V2 API.
|
||
|
|
||
|
Documentation at:
|
||
|
https://docs.what3words.com/api/v2/
|
||
|
|
||
|
.. attention::
|
||
|
Consider using :class:`.What3WordsV3` instead.
|
||
|
"""
|
||
|
|
||
|
geocode_path = '/v2/forward'
|
||
|
reverse_path = '/v2/reverse'
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
api_key,
|
||
|
*,
|
||
|
timeout=DEFAULT_SENTINEL,
|
||
|
proxies=DEFAULT_SENTINEL,
|
||
|
user_agent=None,
|
||
|
ssl_context=DEFAULT_SENTINEL,
|
||
|
adapter_factory=None
|
||
|
):
|
||
|
"""
|
||
|
|
||
|
:param str api_key: Key provided by What3Words
|
||
|
(https://accounts.what3words.com/register).
|
||
|
|
||
|
: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='https',
|
||
|
timeout=timeout,
|
||
|
proxies=proxies,
|
||
|
user_agent=user_agent,
|
||
|
ssl_context=ssl_context,
|
||
|
adapter_factory=adapter_factory,
|
||
|
)
|
||
|
|
||
|
self.api_key = api_key
|
||
|
domain = 'api.what3words.com'
|
||
|
self.geocode_api = '%s://%s%s' % (self.scheme, domain, self.geocode_path)
|
||
|
self.reverse_api = '%s://%s%s' % (self.scheme, domain, self.reverse_path)
|
||
|
|
||
|
def geocode(
|
||
|
self,
|
||
|
query,
|
||
|
*,
|
||
|
lang='en',
|
||
|
exactly_one=True,
|
||
|
timeout=DEFAULT_SENTINEL
|
||
|
):
|
||
|
|
||
|
"""
|
||
|
Return a location point for a `3 words` query. If the `3 words` address
|
||
|
doesn't exist, a :class:`geopy.exc.GeocoderQueryError` exception will be
|
||
|
thrown.
|
||
|
|
||
|
:param str query: The 3-word address you wish to geocode.
|
||
|
|
||
|
:param str lang: two character language code as supported by
|
||
|
the API (https://docs.what3words.com/api/v2/#lang).
|
||
|
|
||
|
:param bool exactly_one: Return one result or a list of results, if
|
||
|
available. Due to the address scheme there is always exactly one
|
||
|
result for each `3 words` address, so this parameter is rather
|
||
|
useless for this geocoder.
|
||
|
|
||
|
: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: :class:`geopy.location.Location` or a list of them, if
|
||
|
``exactly_one=False``.
|
||
|
"""
|
||
|
|
||
|
if not _check_query(query):
|
||
|
raise exc.GeocoderQueryError(
|
||
|
"Search string must be 'word.word.word'"
|
||
|
)
|
||
|
|
||
|
params = {
|
||
|
'addr': query,
|
||
|
'lang': lang.lower(),
|
||
|
'key': self.api_key,
|
||
|
}
|
||
|
|
||
|
url = "?".join((self.geocode_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 _parse_json(self, resources, exactly_one=True):
|
||
|
"""
|
||
|
Parse type, words, latitude, and longitude and language from a
|
||
|
JSON response.
|
||
|
"""
|
||
|
|
||
|
code = resources['status'].get('code')
|
||
|
|
||
|
if code:
|
||
|
# https://docs.what3words.com/api/v2/#errors
|
||
|
exc_msg = "Error returned by What3Words: %s" % resources['status']['message']
|
||
|
if code == 401:
|
||
|
raise exc.GeocoderAuthenticationFailure(exc_msg)
|
||
|
|
||
|
raise exc.GeocoderQueryError(exc_msg)
|
||
|
|
||
|
def parse_resource(resource):
|
||
|
"""
|
||
|
Parse record.
|
||
|
"""
|
||
|
|
||
|
if 'geometry' in resource:
|
||
|
words = resource['words']
|
||
|
position = resource['geometry']
|
||
|
latitude, longitude = position['lat'], position['lng']
|
||
|
if latitude and longitude:
|
||
|
latitude = float(latitude)
|
||
|
longitude = float(longitude)
|
||
|
|
||
|
return Location(words, (latitude, longitude), resource)
|
||
|
else:
|
||
|
raise exc.GeocoderParseError('Error parsing result.')
|
||
|
|
||
|
location = parse_resource(resources)
|
||
|
if exactly_one:
|
||
|
return location
|
||
|
else:
|
||
|
return [location]
|
||
|
|
||
|
def reverse(
|
||
|
self,
|
||
|
query,
|
||
|
*,
|
||
|
lang='en',
|
||
|
exactly_one=True,
|
||
|
timeout=DEFAULT_SENTINEL
|
||
|
):
|
||
|
"""
|
||
|
Return a `3 words` address by location point. Each point on surface has
|
||
|
a `3 words` address, so there's always a non-empty response.
|
||
|
|
||
|
:param query: The coordinates for which you wish to obtain the 3 word
|
||
|
address.
|
||
|
:type query: :class:`geopy.point.Point`, list or tuple of ``(latitude,
|
||
|
longitude)``, or string as ``"%(latitude)s, %(longitude)s"``.
|
||
|
|
||
|
:param str lang: two character language code as supported by the
|
||
|
API (https://docs.what3words.com/api/v2/#lang).
|
||
|
|
||
|
:param bool exactly_one: Return one result or a list of results, if
|
||
|
available. Due to the address scheme there is always exactly one
|
||
|
result for each `3 words` address, so this parameter is rather
|
||
|
useless for this geocoder.
|
||
|
|
||
|
: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: :class:`geopy.location.Location` or a list of them, if
|
||
|
``exactly_one=False``.
|
||
|
|
||
|
"""
|
||
|
lang = lang.lower()
|
||
|
|
||
|
params = {
|
||
|
'coords': self._coerce_point_to_string(query),
|
||
|
'lang': lang.lower(),
|
||
|
'key': self.api_key,
|
||
|
}
|
||
|
|
||
|
url = "?".join((self.reverse_api, urlencode(params)))
|
||
|
|
||
|
logger.debug("%s.reverse: %s", self.__class__.__name__, url)
|
||
|
callback = partial(self._parse_reverse_json, exactly_one=exactly_one)
|
||
|
return self._call_geocoder(url, callback, timeout=timeout)
|
||
|
|
||
|
def _parse_reverse_json(self, resources, exactly_one=True):
|
||
|
"""
|
||
|
Parses a location from a single-result reverse API call.
|
||
|
"""
|
||
|
return self._parse_json(resources, exactly_one)
|
||
|
|
||
|
|
||
|
class What3WordsV3(Geocoder):
|
||
|
"""What3Words geocoder using the V3 API.
|
||
|
|
||
|
Documentation at:
|
||
|
https://developer.what3words.com/public-api/docs
|
||
|
|
||
|
.. versionadded:: 2.2
|
||
|
"""
|
||
|
|
||
|
geocode_path = '/v3/convert-to-coordinates'
|
||
|
reverse_path = '/v3/convert-to-3wa'
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
api_key,
|
||
|
*,
|
||
|
timeout=DEFAULT_SENTINEL,
|
||
|
proxies=DEFAULT_SENTINEL,
|
||
|
user_agent=None,
|
||
|
ssl_context=DEFAULT_SENTINEL,
|
||
|
adapter_factory=None
|
||
|
):
|
||
|
"""
|
||
|
|
||
|
:param str api_key: Key provided by What3Words
|
||
|
(https://accounts.what3words.com/register).
|
||
|
|
||
|
: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='https',
|
||
|
timeout=timeout,
|
||
|
proxies=proxies,
|
||
|
user_agent=user_agent,
|
||
|
ssl_context=ssl_context,
|
||
|
adapter_factory=adapter_factory,
|
||
|
)
|
||
|
|
||
|
self.api_key = api_key
|
||
|
domain = 'api.what3words.com'
|
||
|
self.geocode_api = '%s://%s%s' % (self.scheme, domain, self.geocode_path)
|
||
|
self.reverse_api = '%s://%s%s' % (self.scheme, domain, self.reverse_path)
|
||
|
|
||
|
def geocode(
|
||
|
self,
|
||
|
query,
|
||
|
*,
|
||
|
exactly_one=True,
|
||
|
timeout=DEFAULT_SENTINEL
|
||
|
):
|
||
|
|
||
|
"""
|
||
|
Return a location point for a `3 words` query. If the `3 words` address
|
||
|
doesn't exist, a :class:`geopy.exc.GeocoderQueryError` exception will be
|
||
|
thrown.
|
||
|
|
||
|
:param str query: The 3-word address you wish to geocode.
|
||
|
|
||
|
:param bool exactly_one: Return one result or a list of results, if
|
||
|
available. Due to the address scheme there is always exactly one
|
||
|
result for each `3 words` address, so this parameter is rather
|
||
|
useless for this geocoder.
|
||
|
|
||
|
: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: :class:`geopy.location.Location` or a list of them, if
|
||
|
``exactly_one=False``.
|
||
|
"""
|
||
|
|
||
|
if not _check_query(query):
|
||
|
raise exc.GeocoderQueryError(
|
||
|
"Search string must be 'word.word.word'"
|
||
|
)
|
||
|
|
||
|
params = {
|
||
|
'words': query,
|
||
|
'key': self.api_key,
|
||
|
}
|
||
|
|
||
|
url = "?".join((self.geocode_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 _parse_json(self, resources, exactly_one=True):
|
||
|
"""
|
||
|
Parse type, words, latitude, and longitude and language from a
|
||
|
JSON response.
|
||
|
"""
|
||
|
|
||
|
error = resources.get('error')
|
||
|
|
||
|
if error is not None:
|
||
|
# https://developer.what3words.com/public-api/docs#error-handling
|
||
|
exc_msg = "Error returned by What3Words: %s" % resources["error"]["message"]
|
||
|
exc_code = error.get('code')
|
||
|
if exc_code in ['MissingKey', 'InvalidKey']:
|
||
|
raise exc.GeocoderAuthenticationFailure(exc_msg)
|
||
|
|
||
|
raise exc.GeocoderQueryError(exc_msg)
|
||
|
|
||
|
def parse_resource(resource):
|
||
|
"""
|
||
|
Parse record.
|
||
|
"""
|
||
|
|
||
|
if 'coordinates' in resource:
|
||
|
words = resource['words']
|
||
|
position = resource['coordinates']
|
||
|
latitude, longitude = position['lat'], position['lng']
|
||
|
if latitude and longitude:
|
||
|
latitude = float(latitude)
|
||
|
longitude = float(longitude)
|
||
|
|
||
|
return Location(words, (latitude, longitude), resource)
|
||
|
else:
|
||
|
raise exc.GeocoderParseError('Error parsing result.')
|
||
|
|
||
|
location = parse_resource(resources)
|
||
|
if exactly_one:
|
||
|
return location
|
||
|
else:
|
||
|
return [location]
|
||
|
|
||
|
def reverse(
|
||
|
self,
|
||
|
query,
|
||
|
*,
|
||
|
lang='en',
|
||
|
exactly_one=True,
|
||
|
timeout=DEFAULT_SENTINEL
|
||
|
):
|
||
|
"""
|
||
|
Return a `3 words` address by location point. Each point on surface has
|
||
|
a `3 words` address, so there's always a non-empty response.
|
||
|
|
||
|
:param query: The coordinates for which you wish to obtain the 3 word
|
||
|
address.
|
||
|
:type query: :class:`geopy.point.Point`, list or tuple of ``(latitude,
|
||
|
longitude)``, or string as ``"%(latitude)s, %(longitude)s"``.
|
||
|
|
||
|
:param str lang: two character language code as supported by the
|
||
|
API (https://developer.what3words.com/public-api/docs#available-languages).
|
||
|
|
||
|
:param bool exactly_one: Return one result or a list of results, if
|
||
|
available. Due to the address scheme there is always exactly one
|
||
|
result for each `3 words` address, so this parameter is rather
|
||
|
useless for this geocoder.
|
||
|
|
||
|
: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: :class:`geopy.location.Location` or a list of them, if
|
||
|
``exactly_one=False``.
|
||
|
|
||
|
"""
|
||
|
lang = lang.lower()
|
||
|
|
||
|
params = {
|
||
|
'coordinates': self._coerce_point_to_string(query),
|
||
|
'language': lang.lower(),
|
||
|
'key': self.api_key,
|
||
|
}
|
||
|
|
||
|
url = "?".join((self.reverse_api, urlencode(params)))
|
||
|
|
||
|
logger.debug("%s.reverse: %s", self.__class__.__name__, url)
|
||
|
callback = partial(self._parse_reverse_json, exactly_one=exactly_one)
|
||
|
return self._call_geocoder(url, callback, timeout=timeout)
|
||
|
|
||
|
def _parse_reverse_json(self, resources, exactly_one=True):
|
||
|
"""
|
||
|
Parses a location from a single-result reverse API call.
|
||
|
"""
|
||
|
return self._parse_json(resources, exactly_one)
|