1import re
2from functools import partial
3from urllib.parse import urlencode
4
5from geopy import exc
6from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
7from geopy.location import Location
8from geopy.util import logger
9
10__all__ = ("What3Words", "What3WordsV3")
11
12_MULTIPLE_WORD_RE = re.compile(
13    r"[^\W\d\_]+\.{1,1}[^\W\d\_]+\.{1,1}[^\W\d\_]+$", re.U
14)
15
16
17def _check_query(query):
18    """
19    Check query validity with regex
20    """
21    if not _MULTIPLE_WORD_RE.match(query):
22        return False
23    else:
24        return True
25
26
27class What3Words(Geocoder):
28    """What3Words geocoder using the legacy V2 API.
29
30    Documentation at:
31        https://docs.what3words.com/api/v2/
32
33    .. attention::
34        Consider using :class:`.What3WordsV3` instead.
35    """
36
37    geocode_path = '/v2/forward'
38    reverse_path = '/v2/reverse'
39
40    def __init__(
41            self,
42            api_key,
43            *,
44            timeout=DEFAULT_SENTINEL,
45            proxies=DEFAULT_SENTINEL,
46            user_agent=None,
47            ssl_context=DEFAULT_SENTINEL,
48            adapter_factory=None
49    ):
50        """
51
52        :param str api_key: Key provided by What3Words
53            (https://accounts.what3words.com/register).
54
55        :param int timeout:
56            See :attr:`geopy.geocoders.options.default_timeout`.
57
58        :param dict proxies:
59            See :attr:`geopy.geocoders.options.default_proxies`.
60
61        :param str user_agent:
62            See :attr:`geopy.geocoders.options.default_user_agent`.
63
64        :type ssl_context: :class:`ssl.SSLContext`
65        :param ssl_context:
66            See :attr:`geopy.geocoders.options.default_ssl_context`.
67
68        :param callable adapter_factory:
69            See :attr:`geopy.geocoders.options.default_adapter_factory`.
70
71            .. versionadded:: 2.0
72        """
73        super().__init__(
74            scheme='https',
75            timeout=timeout,
76            proxies=proxies,
77            user_agent=user_agent,
78            ssl_context=ssl_context,
79            adapter_factory=adapter_factory,
80        )
81
82        self.api_key = api_key
83        domain = 'api.what3words.com'
84        self.geocode_api = '%s://%s%s' % (self.scheme, domain, self.geocode_path)
85        self.reverse_api = '%s://%s%s' % (self.scheme, domain, self.reverse_path)
86
87    def geocode(
88            self,
89            query,
90            *,
91            lang='en',
92            exactly_one=True,
93            timeout=DEFAULT_SENTINEL
94    ):
95
96        """
97        Return a location point for a `3 words` query. If the `3 words` address
98        doesn't exist, a :class:`geopy.exc.GeocoderQueryError` exception will be
99        thrown.
100
101        :param str query: The 3-word address you wish to geocode.
102
103        :param str lang: two character language code as supported by
104            the API (https://docs.what3words.com/api/v2/#lang).
105
106        :param bool exactly_one: Return one result or a list of results, if
107            available. Due to the address scheme there is always exactly one
108            result for each `3 words` address, so this parameter is rather
109            useless for this geocoder.
110
111        :param int timeout: Time, in seconds, to wait for the geocoding service
112            to respond before raising a :class:`geopy.exc.GeocoderTimedOut`
113            exception. Set this only if you wish to override, on this call
114            only, the value set during the geocoder's initialization.
115
116        :rtype: :class:`geopy.location.Location` or a list of them, if
117            ``exactly_one=False``.
118        """
119
120        if not _check_query(query):
121            raise exc.GeocoderQueryError(
122                "Search string must be 'word.word.word'"
123            )
124
125        params = {
126            'addr': query,
127            'lang': lang.lower(),
128            'key': self.api_key,
129        }
130
131        url = "?".join((self.geocode_api, urlencode(params)))
132        logger.debug("%s.geocode: %s", self.__class__.__name__, url)
133        callback = partial(self._parse_json, exactly_one=exactly_one)
134        return self._call_geocoder(url, callback, timeout=timeout)
135
136    def _parse_json(self, resources, exactly_one=True):
137        """
138        Parse type, words, latitude, and longitude and language from a
139        JSON response.
140        """
141
142        code = resources['status'].get('code')
143
144        if code:
145            # https://docs.what3words.com/api/v2/#errors
146            exc_msg = "Error returned by What3Words: %s" % resources['status']['message']
147            if code == 401:
148                raise exc.GeocoderAuthenticationFailure(exc_msg)
149
150            raise exc.GeocoderQueryError(exc_msg)
151
152        def parse_resource(resource):
153            """
154            Parse record.
155            """
156
157            if 'geometry' in resource:
158                words = resource['words']
159                position = resource['geometry']
160                latitude, longitude = position['lat'], position['lng']
161                if latitude and longitude:
162                    latitude = float(latitude)
163                    longitude = float(longitude)
164
165                return Location(words, (latitude, longitude), resource)
166            else:
167                raise exc.GeocoderParseError('Error parsing result.')
168
169        location = parse_resource(resources)
170        if exactly_one:
171            return location
172        else:
173            return [location]
174
175    def reverse(
176            self,
177            query,
178            *,
179            lang='en',
180            exactly_one=True,
181            timeout=DEFAULT_SENTINEL
182    ):
183        """
184        Return a `3 words` address by location point. Each point on surface has
185        a `3 words` address, so there's always a non-empty response.
186
187        :param query: The coordinates for which you wish to obtain the 3 word
188            address.
189        :type query: :class:`geopy.point.Point`, list or tuple of ``(latitude,
190            longitude)``, or string as ``"%(latitude)s, %(longitude)s"``.
191
192        :param str lang: two character language code as supported by the
193            API (https://docs.what3words.com/api/v2/#lang).
194
195        :param bool exactly_one: Return one result or a list of results, if
196            available. Due to the address scheme there is always exactly one
197            result for each `3 words` address, so this parameter is rather
198            useless for this geocoder.
199
200        :param int timeout: Time, in seconds, to wait for the geocoding service
201            to respond before raising a :class:`geopy.exc.GeocoderTimedOut`
202            exception. Set this only if you wish to override, on this call
203            only, the value set during the geocoder's initialization.
204
205        :rtype: :class:`geopy.location.Location` or a list of them, if
206            ``exactly_one=False``.
207
208        """
209        lang = lang.lower()
210
211        params = {
212            'coords': self._coerce_point_to_string(query),
213            'lang': lang.lower(),
214            'key': self.api_key,
215        }
216
217        url = "?".join((self.reverse_api, urlencode(params)))
218
219        logger.debug("%s.reverse: %s", self.__class__.__name__, url)
220        callback = partial(self._parse_reverse_json, exactly_one=exactly_one)
221        return self._call_geocoder(url, callback, timeout=timeout)
222
223    def _parse_reverse_json(self, resources, exactly_one=True):
224        """
225        Parses a location from a single-result reverse API call.
226        """
227        return self._parse_json(resources, exactly_one)
228
229
230class What3WordsV3(Geocoder):
231    """What3Words geocoder using the V3 API.
232
233    Documentation at:
234        https://developer.what3words.com/public-api/docs
235
236    .. versionadded:: 2.2
237    """
238
239    geocode_path = '/v3/convert-to-coordinates'
240    reverse_path = '/v3/convert-to-3wa'
241
242    def __init__(
243            self,
244            api_key,
245            *,
246            timeout=DEFAULT_SENTINEL,
247            proxies=DEFAULT_SENTINEL,
248            user_agent=None,
249            ssl_context=DEFAULT_SENTINEL,
250            adapter_factory=None
251    ):
252        """
253
254        :param str api_key: Key provided by What3Words
255            (https://accounts.what3words.com/register).
256
257        :param int timeout:
258            See :attr:`geopy.geocoders.options.default_timeout`.
259
260        :param dict proxies:
261            See :attr:`geopy.geocoders.options.default_proxies`.
262
263        :param str user_agent:
264            See :attr:`geopy.geocoders.options.default_user_agent`.
265
266        :type ssl_context: :class:`ssl.SSLContext`
267        :param ssl_context:
268            See :attr:`geopy.geocoders.options.default_ssl_context`.
269
270        :param callable adapter_factory:
271            See :attr:`geopy.geocoders.options.default_adapter_factory`.
272        """
273        super().__init__(
274            scheme='https',
275            timeout=timeout,
276            proxies=proxies,
277            user_agent=user_agent,
278            ssl_context=ssl_context,
279            adapter_factory=adapter_factory,
280        )
281
282        self.api_key = api_key
283        domain = 'api.what3words.com'
284        self.geocode_api = '%s://%s%s' % (self.scheme, domain, self.geocode_path)
285        self.reverse_api = '%s://%s%s' % (self.scheme, domain, self.reverse_path)
286
287    def geocode(
288            self,
289            query,
290            *,
291            exactly_one=True,
292            timeout=DEFAULT_SENTINEL
293    ):
294
295        """
296        Return a location point for a `3 words` query. If the `3 words` address
297        doesn't exist, a :class:`geopy.exc.GeocoderQueryError` exception will be
298        thrown.
299
300        :param str query: The 3-word address you wish to geocode.
301
302        :param bool exactly_one: Return one result or a list of results, if
303            available. Due to the address scheme there is always exactly one
304            result for each `3 words` address, so this parameter is rather
305            useless for this geocoder.
306
307        :param int timeout: Time, in seconds, to wait for the geocoding service
308            to respond before raising a :class:`geopy.exc.GeocoderTimedOut`
309            exception. Set this only if you wish to override, on this call
310            only, the value set during the geocoder's initialization.
311
312        :rtype: :class:`geopy.location.Location` or a list of them, if
313            ``exactly_one=False``.
314        """
315
316        if not _check_query(query):
317            raise exc.GeocoderQueryError(
318                "Search string must be 'word.word.word'"
319            )
320
321        params = {
322            'words': query,
323            'key': self.api_key,
324        }
325
326        url = "?".join((self.geocode_api, urlencode(params)))
327        logger.debug("%s.geocode: %s", self.__class__.__name__, url)
328        callback = partial(self._parse_json, exactly_one=exactly_one)
329        return self._call_geocoder(url, callback, timeout=timeout)
330
331    def _parse_json(self, resources, exactly_one=True):
332        """
333        Parse type, words, latitude, and longitude and language from a
334        JSON response.
335        """
336
337        error = resources.get('error')
338
339        if error is not None:
340            # https://developer.what3words.com/public-api/docs#error-handling
341            exc_msg = "Error returned by What3Words: %s" % resources["error"]["message"]
342            exc_code = error.get('code')
343            if exc_code in ['MissingKey', 'InvalidKey']:
344                raise exc.GeocoderAuthenticationFailure(exc_msg)
345
346            raise exc.GeocoderQueryError(exc_msg)
347
348        def parse_resource(resource):
349            """
350            Parse record.
351            """
352
353            if 'coordinates' in resource:
354                words = resource['words']
355                position = resource['coordinates']
356                latitude, longitude = position['lat'], position['lng']
357                if latitude and longitude:
358                    latitude = float(latitude)
359                    longitude = float(longitude)
360
361                return Location(words, (latitude, longitude), resource)
362            else:
363                raise exc.GeocoderParseError('Error parsing result.')
364
365        location = parse_resource(resources)
366        if exactly_one:
367            return location
368        else:
369            return [location]
370
371    def reverse(
372            self,
373            query,
374            *,
375            lang='en',
376            exactly_one=True,
377            timeout=DEFAULT_SENTINEL
378    ):
379        """
380        Return a `3 words` address by location point. Each point on surface has
381        a `3 words` address, so there's always a non-empty response.
382
383        :param query: The coordinates for which you wish to obtain the 3 word
384            address.
385        :type query: :class:`geopy.point.Point`, list or tuple of ``(latitude,
386            longitude)``, or string as ``"%(latitude)s, %(longitude)s"``.
387
388        :param str lang: two character language code as supported by the
389            API (https://developer.what3words.com/public-api/docs#available-languages).
390
391        :param bool exactly_one: Return one result or a list of results, if
392            available. Due to the address scheme there is always exactly one
393            result for each `3 words` address, so this parameter is rather
394            useless for this geocoder.
395
396        :param int timeout: Time, in seconds, to wait for the geocoding service
397            to respond before raising a :class:`geopy.exc.GeocoderTimedOut`
398            exception. Set this only if you wish to override, on this call
399            only, the value set during the geocoder's initialization.
400
401        :rtype: :class:`geopy.location.Location` or a list of them, if
402            ``exactly_one=False``.
403
404        """
405        lang = lang.lower()
406
407        params = {
408            'coordinates': self._coerce_point_to_string(query),
409            'language': lang.lower(),
410            'key': self.api_key,
411        }
412
413        url = "?".join((self.reverse_api, urlencode(params)))
414
415        logger.debug("%s.reverse: %s", self.__class__.__name__, url)
416        callback = partial(self._parse_reverse_json, exactly_one=exactly_one)
417        return self._call_geocoder(url, callback, timeout=timeout)
418
419    def _parse_reverse_json(self, resources, exactly_one=True):
420        """
421        Parses a location from a single-result reverse API call.
422        """
423        return self._parse_json(resources, exactly_one)
424