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