360 lines
12 KiB
Python
360 lines
12 KiB
Python
from functools import partial
|
|
from urllib.parse import urlencode
|
|
|
|
from geopy.exc import (
|
|
GeocoderAuthenticationFailure,
|
|
GeocoderInsufficientPrivileges,
|
|
GeocoderQueryError,
|
|
GeocoderQuotaExceeded,
|
|
GeocoderServiceError,
|
|
)
|
|
from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
|
|
from geopy.location import Location
|
|
from geopy.timezone import (
|
|
ensure_pytz_is_installed,
|
|
from_fixed_gmt_offset,
|
|
from_timezone_name,
|
|
)
|
|
from geopy.util import logger
|
|
|
|
__all__ = ("GeoNames", )
|
|
|
|
|
|
class GeoNames(Geocoder):
|
|
"""GeoNames geocoder.
|
|
|
|
Documentation at:
|
|
http://www.geonames.org/export/geonames-search.html
|
|
|
|
Reverse geocoding documentation at:
|
|
http://www.geonames.org/export/web-services.html#findNearbyPlaceName
|
|
"""
|
|
|
|
geocode_path = '/searchJSON'
|
|
reverse_path = '/findNearbyPlaceNameJSON'
|
|
reverse_nearby_path = '/findNearbyJSON'
|
|
timezone_path = '/timezoneJSON'
|
|
|
|
def __init__(
|
|
self,
|
|
username,
|
|
*,
|
|
timeout=DEFAULT_SENTINEL,
|
|
proxies=DEFAULT_SENTINEL,
|
|
user_agent=None,
|
|
ssl_context=DEFAULT_SENTINEL,
|
|
adapter_factory=None,
|
|
scheme='http'
|
|
):
|
|
"""
|
|
|
|
:param str username: GeoNames username, required. Sign up here:
|
|
http://www.geonames.org/login
|
|
|
|
: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
|
|
|
|
:param str scheme:
|
|
See :attr:`geopy.geocoders.options.default_scheme`. Note that
|
|
at the time of writing GeoNames doesn't support `https`, so
|
|
the default scheme is `http`. The value of
|
|
:attr:`geopy.geocoders.options.default_scheme` is not respected.
|
|
This parameter is present to make it possible to switch to
|
|
`https` once GeoNames adds support for it.
|
|
"""
|
|
super().__init__(
|
|
scheme=scheme,
|
|
timeout=timeout,
|
|
proxies=proxies,
|
|
user_agent=user_agent,
|
|
ssl_context=ssl_context,
|
|
adapter_factory=adapter_factory,
|
|
)
|
|
self.username = username
|
|
|
|
domain = 'api.geonames.org'
|
|
self.api = (
|
|
"%s://%s%s" % (self.scheme, domain, self.geocode_path)
|
|
)
|
|
self.api_reverse = (
|
|
"%s://%s%s" % (self.scheme, domain, self.reverse_path)
|
|
)
|
|
self.api_reverse_nearby = (
|
|
"%s://%s%s" % (self.scheme, domain, self.reverse_nearby_path)
|
|
)
|
|
self.api_timezone = (
|
|
"%s://%s%s" % (self.scheme, domain, self.timezone_path)
|
|
)
|
|
|
|
def geocode(
|
|
self,
|
|
query,
|
|
*,
|
|
exactly_one=True,
|
|
timeout=DEFAULT_SENTINEL,
|
|
country=None,
|
|
country_bias=None
|
|
):
|
|
"""
|
|
Return a location point by address.
|
|
|
|
:param str query: The address or query you wish to geocode.
|
|
|
|
: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 country: Limit records to the specified countries.
|
|
Two letter country code ISO-3166 (e.g. ``FR``). Might be
|
|
a single string or a list of strings.
|
|
:type country: str or list
|
|
|
|
:param str country_bias: Records from the country_bias are listed first.
|
|
Two letter country code ISO-3166.
|
|
|
|
:rtype: ``None``, :class:`geopy.location.Location` or a list of them, if
|
|
``exactly_one=False``.
|
|
"""
|
|
params = [
|
|
('q', query),
|
|
('username', self.username),
|
|
]
|
|
|
|
if country_bias:
|
|
params.append(('countryBias', country_bias))
|
|
|
|
if not country:
|
|
country = []
|
|
if isinstance(country, str):
|
|
country = [country]
|
|
for country_item in country:
|
|
params.append(('country', country_item))
|
|
|
|
if exactly_one:
|
|
params.append(('maxRows', 1))
|
|
url = "?".join((self.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,
|
|
feature_code=None,
|
|
lang=None,
|
|
find_nearby_type='findNearbyPlaceName'
|
|
):
|
|
"""
|
|
Return an address by location point.
|
|
|
|
:param query: The coordinates for which you wish to obtain the
|
|
closest human-readable addresses.
|
|
:type query: :class:`geopy.point.Point`, list or tuple of ``(latitude,
|
|
longitude)``, or string as ``"%(latitude)s, %(longitude)s"``.
|
|
|
|
: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 feature_code: A GeoNames feature code
|
|
|
|
:param str lang: language of the returned ``name`` element (the pseudo
|
|
language code 'local' will return it in local language)
|
|
Full list of supported languages can be found here:
|
|
https://www.geonames.org/countries/
|
|
|
|
:param str find_nearby_type: A flag to switch between different
|
|
GeoNames API endpoints. The default value is ``findNearbyPlaceName``
|
|
which returns the closest populated place. Another currently
|
|
implemented option is ``findNearby`` which returns
|
|
the closest toponym for the lat/lng query.
|
|
|
|
:rtype: ``None``, :class:`geopy.location.Location` or a list of them, if
|
|
``exactly_one=False``.
|
|
|
|
"""
|
|
|
|
try:
|
|
lat, lng = self._coerce_point_to_string(query).split(',')
|
|
except ValueError:
|
|
raise ValueError("Must be a coordinate pair or Point")
|
|
|
|
if find_nearby_type == 'findNearbyPlaceName': # default
|
|
if feature_code:
|
|
raise ValueError(
|
|
"find_nearby_type=findNearbyPlaceName doesn't support "
|
|
"the `feature_code` param"
|
|
)
|
|
params = self._reverse_find_nearby_place_name_params(
|
|
lat=lat,
|
|
lng=lng,
|
|
lang=lang,
|
|
)
|
|
url = "?".join((self.api_reverse, urlencode(params)))
|
|
elif find_nearby_type == 'findNearby':
|
|
if lang:
|
|
raise ValueError(
|
|
"find_nearby_type=findNearby doesn't support the `lang` param"
|
|
)
|
|
params = self._reverse_find_nearby_params(
|
|
lat=lat,
|
|
lng=lng,
|
|
feature_code=feature_code,
|
|
)
|
|
url = "?".join((self.api_reverse_nearby, urlencode(params)))
|
|
else:
|
|
raise GeocoderQueryError(
|
|
'`%s` find_nearby_type is not supported by geopy' % find_nearby_type
|
|
)
|
|
|
|
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 _reverse_find_nearby_params(self, lat, lng, feature_code):
|
|
params = {
|
|
'lat': lat,
|
|
'lng': lng,
|
|
'username': self.username,
|
|
}
|
|
if feature_code:
|
|
params['featureCode'] = feature_code
|
|
return params
|
|
|
|
def _reverse_find_nearby_place_name_params(self, lat, lng, lang):
|
|
params = {
|
|
'lat': lat,
|
|
'lng': lng,
|
|
'username': self.username,
|
|
}
|
|
if lang:
|
|
params['lang'] = lang
|
|
return params
|
|
|
|
def reverse_timezone(self, query, *, timeout=DEFAULT_SENTINEL):
|
|
"""
|
|
Find the timezone for a point in `query`.
|
|
|
|
GeoNames always returns a timezone: if the point being queried
|
|
doesn't have an assigned Olson timezone id, a ``pytz.FixedOffset``
|
|
timezone is used to produce the :class:`geopy.timezone.Timezone`.
|
|
|
|
:param query: The coordinates for which you want a timezone.
|
|
:type query: :class:`geopy.point.Point`, list or tuple of (latitude,
|
|
longitude), or string as "%(latitude)s, %(longitude)s"
|
|
|
|
: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.timezone.Timezone`.
|
|
"""
|
|
ensure_pytz_is_installed()
|
|
|
|
try:
|
|
lat, lng = self._coerce_point_to_string(query).split(',')
|
|
except ValueError:
|
|
raise ValueError("Must be a coordinate pair or Point")
|
|
|
|
params = {
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"username": self.username,
|
|
}
|
|
|
|
url = "?".join((self.api_timezone, urlencode(params)))
|
|
|
|
logger.debug("%s.reverse_timezone: %s", self.__class__.__name__, url)
|
|
return self._call_geocoder(url, self._parse_json_timezone, timeout=timeout)
|
|
|
|
def _raise_for_error(self, body):
|
|
err = body.get('status')
|
|
if err:
|
|
code = err['value']
|
|
message = err['message']
|
|
# http://www.geonames.org/export/webservice-exception.html
|
|
if message.startswith("user account not enabled to use"):
|
|
raise GeocoderInsufficientPrivileges(message)
|
|
if code == 10:
|
|
raise GeocoderAuthenticationFailure(message)
|
|
if code in (18, 19, 20):
|
|
raise GeocoderQuotaExceeded(message)
|
|
raise GeocoderServiceError(message)
|
|
|
|
def _parse_json_timezone(self, response):
|
|
self._raise_for_error(response)
|
|
|
|
timezone_id = response.get("timezoneId")
|
|
if timezone_id is None:
|
|
# Sometimes (e.g. for Antarctica) GeoNames doesn't return
|
|
# a `timezoneId` value, but it returns GMT offsets.
|
|
# Apparently GeoNames always returns these offsets -- for
|
|
# every single point on the globe.
|
|
raw_offset = response["rawOffset"]
|
|
return from_fixed_gmt_offset(raw_offset, raw=response)
|
|
else:
|
|
return from_timezone_name(timezone_id, raw=response)
|
|
|
|
def _parse_json(self, doc, exactly_one):
|
|
"""
|
|
Parse JSON response body.
|
|
"""
|
|
places = doc.get('geonames', [])
|
|
self._raise_for_error(doc)
|
|
if not len(places):
|
|
return None
|
|
|
|
def parse_code(place):
|
|
"""
|
|
Parse each record.
|
|
"""
|
|
latitude = place.get('lat', None)
|
|
longitude = place.get('lng', None)
|
|
if latitude and longitude:
|
|
latitude = float(latitude)
|
|
longitude = float(longitude)
|
|
else:
|
|
return None
|
|
|
|
placename = place.get('name')
|
|
state = place.get('adminName1', None)
|
|
country = place.get('countryName', None)
|
|
|
|
location = ', '.join(
|
|
[x for x in [placename, state, country] if x]
|
|
)
|
|
|
|
return Location(location, (latitude, longitude), place)
|
|
|
|
if exactly_one:
|
|
return parse_code(places[0])
|
|
else:
|
|
return [parse_code(place) for place in places]
|