Repository: geopy/geopy
Pull request #332 (2018-11-04 22:07:42)

Add geopy.Timezone -- a timezone wrapper
Currently only GoogleV3 implements a query returning a timezone. It returns pytz.timezone directly, and the raw geocoder response is not accessible. This PR introduces a `geopy.Location`-like wrapper for pytz.timezone -- `geopy.Timezone`.

For a smooth transition I decided to introduce a new `GoogleV3.reverse_timezone` method, which is similar to `GoogleV3.timezone` except that it returns `geopy.Timezone` object. I don't really like the `reverse_timezone` method name, but I couldn't come up with a better idea of smooth transition from
pytz.timezone as return type to geopy.Timezone.

This feature came out from #327.


Patch:
diff --git a/docs/index.rst b/docs/index.rst
index 8a80a91..510a532 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -287,11 +287,14 @@ Data
 ~~~~
 
 .. autoclass:: geopy.location.Location
-    :members: __init__, address, latitude, longitude, altitude, point, raw
+    :members: address, latitude, longitude, altitude, point, raw
 
 .. autoclass:: geopy.point.Point
     :members: __new__, from_string, from_sequence, from_point
 
+.. autoclass:: geopy.timezone.Timezone
+    :members: pytz_timezone, raw
+
 Exceptions
 ~~~~~~~~~~
 
diff --git a/geopy/__init__.py b/geopy/__init__.py
index ac8475a..4af9d38 100644
--- a/geopy/__init__.py
+++ b/geopy/__init__.py
@@ -11,9 +11,13 @@ PyPy3. geopy does not and will not support CPython 2.6.
 
 from geopy.location import Location  # noqa
 from geopy.point import Point  # noqa
+from geopy.timezone import Timezone  # noqa
 from geopy.util import __version__  # noqa
 
 from geopy.geocoders import *  # noqa
 # geopy.geocoders.options must not be importable as `geopy.options`,
 # because that is ambiguous (which options are that).
 del options  # noqa
+
+# `__all__` is intentionally not defined in order to not duplicate
+# the same list of geocoders as in `geopy.geocoders` package.
diff --git a/geopy/geocoders/googlev3.py b/geopy/geocoders/googlev3.py
index befd0b2..1c52d01 100644
--- a/geopy/geocoders/googlev3.py
+++ b/geopy/geocoders/googlev3.py
@@ -2,27 +2,21 @@ import base64
 import hashlib
 import hmac
 import warnings
+from calendar import timegm
+from datetime import datetime
+from numbers import Number
 
 from geopy.compat import urlencode
 from geopy.exc import (
     ConfigurationError,
-    GeocoderParseError,
     GeocoderQueryError,
     GeocoderQuotaExceeded,
 )
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
 from geopy.location import Location
+from geopy.timezone import from_timezone_name, ensure_pytz_is_installed
 from geopy.util import logger
 
-try:
-    from pytz import timezone, UnknownTimeZoneError
-    from calendar import timegm
-    from datetime import datetime
-    from numbers import Number
-    pytz_available = True
-except ImportError:
-    pytz_available = False
-
 
 __all__ = ("GoogleV3", )
 
@@ -329,12 +323,14 @@ class GoogleV3(Geocoder):
 
     def timezone(self, location, at_time=None, timeout=DEFAULT_SENTINEL):
         """
-        **This is an unstable API.**
+        Find the timezone a `location` was in for a specified `at_time`,
+        and return a pytz timezone object.
 
-        Finds the timezone a `location` was in for a specified `at_time`,
-        and returns a pytz timezone object.
+        .. versionadded:: 1.2.0
 
-            .. versionadded:: 1.2.0
+        .. deprecated:: 1.18.0
+           Use :meth:`GoogleV3.reverse_timezone` instead. This method
+           will be removed in geopy 2.0.
 
         :param location: The coordinates for which you want a timezone.
         :type location: :class:`geopy.point.Point`, list or tuple of (latitude,
@@ -342,8 +338,15 @@ class GoogleV3(Geocoder):
 
         :param at_time: The time at which you want the timezone of this
             location. This is optional, and defaults to the time that the
-            function is called in UTC.
-        :type at_time: int or float or datetime
+            function is called in UTC. Timezone-aware datetimes are correctly
+            handled and naive datetimes are silently treated as UTC.
+
+            .. versionchanged:: 1.18.0
+               Previously this parameter accepted raw unix timestamp as
+               int or float. This is now deprecated in favor of datetimes
+               and support for numbers will be removed in geopy 2.0.
+
+        :type at_time: :class:`datetime.datetime` or None
 
         :param int timeout: Time, in seconds, to wait for the geocoding service
             to respond before raising a :class:`geopy.exc.GeocoderTimedOut`
@@ -352,24 +355,43 @@ class GoogleV3(Geocoder):
 
         :rtype: pytz timezone. See :func:`pytz.timezone`.
         """
-        if not pytz_available:
-            raise ImportError(
-                'pytz must be installed in order to locate timezones. '
-                ' Install with `pip install geopy -e ".[timezone]"`.'
-            )
-        location = self._coerce_point_to_string(location)
 
-        if isinstance(at_time, Number):
-            timestamp = at_time
-        elif isinstance(at_time, datetime):
-            timestamp = timegm(at_time.utctimetuple())
-        elif at_time is None:
-            timestamp = timegm(datetime.utcnow().utctimetuple())
-        else:
-            raise GeocoderQueryError(
-                "`at_time` must be an epoch integer or "
-                "datetime.datetime object"
-            )
+        warnings.warn('%(cls)s.timezone method is deprecated in favor of '
+                      '%(cls)s.reverse_timezone, which returns geopy.Timezone '
+                      'object containing pytz timezone and a raw response '
+                      'instead of just pytz timezone. This method will '
+                      'be removed in geopy 2.0.' % dict(cls=type(self).__name__),
+                      DeprecationWarning)
+        return self.reverse_timezone(location, at_time, timeout).pytz_timezone
+
+    def reverse_timezone(self, query, at_time=None, timeout=DEFAULT_SENTINEL):
+        """
+        Find the timezone a point in `query` was in for a specified `at_time`.
+
+        .. versionadded:: 1.18.0
+
+        :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 at_time: The time at which you want the timezone of this
+            location. This is optional, and defaults to the time that the
+            function is called in UTC. Timezone-aware datetimes are correctly
+            handled and naive datetimes are silently treated as UTC.
+        :type at_time: :class:`datetime.datetime` or None
+
+        :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()
+
+        location = self._coerce_point_to_string(query)
+
+        timestamp = self._normalize_timezone_at_time(at_time)
 
         params = {
             "location": location,
@@ -386,19 +408,31 @@ class GoogleV3(Geocoder):
         if status != 'OK':
             self._check_status(status)
 
-        try:
-            tz = timezone(response["timeZoneId"])
-        except UnknownTimeZoneError:
-            raise GeocoderParseError(
-                "pytz could not parse the timezone identifier (%s) "
-                "returned by the service." % response["timeZoneId"]
+        return self._parse_json_timezone(response)
+
+    def _parse_json_timezone(self, response):
+        return from_timezone_name(response["timeZoneId"], raw=response)
+
+    def _normalize_timezone_at_time(self, at_time):
+        if at_time is None:
+            timestamp = timegm(datetime.utcnow().utctimetuple())
+        elif isinstance(at_time, Number):
+            warnings.warn(
+                'Support for `at_time` as int/float is deprecated '
+                'and will be removed in geopy 2.0. '
+                'Pass a `datetime.datetime` instance instead.',
+                DeprecationWarning
             )
-        except KeyError:
-            raise GeocoderParseError(
-                "geopy could not find a timezone in this response: %s" %
-                response
+            timestamp = at_time
+        elif isinstance(at_time, datetime):
+            # Naive datetimes are silently treated as UTC.
+            # Timezone-aware datetimes are handled correctly.
+            timestamp = timegm(at_time.utctimetuple())
+        else:
+            raise GeocoderQueryError(
+                "`at_time` must be an instance of `datetime.datetime`"
             )
-        return tz
+        return timestamp
 
     def _parse_json(self, page, exactly_one=True):
         '''Returns location, (latitude, longitude) from json feed.'''
diff --git a/geopy/timezone.py b/geopy/timezone.py
new file mode 100644
index 0000000..1fd2322
--- /dev/null
+++ b/geopy/timezone.py
@@ -0,0 +1,91 @@
+from geopy.compat import text_type
+from geopy.exc import GeocoderParseError
+
+try:
+    import pytz
+    pytz_available = True
+except ImportError:
+    pytz_available = False
+
+
+def ensure_pytz_is_installed():
+    if not pytz_available:
+        raise ImportError(
+            'pytz must be installed in order to locate timezones. '
+            ' Install with `pip install geopy -e ".[timezone]"`.'
+        )
+
+
+def from_timezone_name(timezone_name, raw=None):
+    ensure_pytz_is_installed()
+    try:
+        pytz_timezone = pytz.timezone(timezone_name)
+    except pytz.UnknownTimeZoneError:
+        raise GeocoderParseError(
+            "pytz could not parse the timezone identifier (%s) "
+            "returned by the service." % timezone_name
+        )
+    except KeyError:
+        raise GeocoderParseError(
+            "geopy could not find a timezone in this response: %s" %
+            raw
+        )
+    return Timezone(pytz_timezone, raw)
+
+
+class Timezone(object):
+    """
+    Contains a parsed response for a timezone request, which is
+    implemented in few geocoders which provide such lookups.
+
+    .. versionadded:: 1.18.0
+    """
+
+    __slots__ = ("_pytz_timezone", "_raw")
+
+    def __init__(self, pytz_timezone, raw=None):
+        self._pytz_timezone = pytz_timezone
+        self._raw = raw
+
+    @property
+    def pytz_timezone(self):
+        """
+        pytz timezone instance.
+
+        :rtype: :class:`pytz.tzinfo.BaseTzInfo`
+        """
+        return self._pytz_timezone
+
+    @property
+    def raw(self):
+        """
+        Timezone's raw, unparsed geocoder response. For details on this,
+        consult the service's documentation.
+
+        :rtype: dict or None
+        """
+        return self._raw
+
+    def __unicode__(self):
+        return text_type(self._pytz_timezone)
+
+    __str__ = __unicode__
+
+    def __repr__(self):
+        return "Timezone(%s)" % repr(self.pytz_timezone)
+
+    def __getstate__(self):
+        return self._pytz_timezone, self._raw
+
+    def __setstate__(self, state):
+        self._pytz_timezone, self._raw = state
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, Timezone) and
+            self._pytz_timezone == other._pytz_timezone and
+            self.raw == other.raw
+        )
+
+    def __ne__(self, other):
+        return not (self == other)


Test patch:
diff --git a/test/geocoders/googlev3.py b/test/geocoders/googlev3.py
index 17f56e5..c99868e 100644
--- a/test/geocoders/googlev3.py
+++ b/test/geocoders/googlev3.py
@@ -19,8 +19,20 @@ class GoogleV3TestCase(GeocoderTestBase):
         cls.geocoder = GoogleV3(api_key=env.get('GOOGLE_KEY'))
 
     def timezone_run(self, payload, expected):
-        tz = self._make_request(self.geocoder.timezone, **payload)
-        self.assertEqual(tz, expected)
+        timezone = self._make_request(self.geocoder.reverse_timezone, **payload)
+        self.assertEqual(timezone.pytz_timezone, expected)
+
+        # `timezone` method is deprecated, but we still support it.
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter('always')
+            if 'query' in payload:
+                payload['location'] = payload['query']
+                del payload['query']
+            pytz_timezone = self._make_request(self.geocoder.timezone, **payload)
+            self.assertEqual(pytz_timezone, expected)
+            self.assertLess(0, len(w))
+
+        return timezone
 
     def test_user_agent_custom(self):
         geocoder = GoogleV3(
@@ -199,26 +211,47 @@ class GoogleV3TestCase(GeocoderTestBase):
         GoogleV3.timezone returns pytz object from datetime
         """
         self.timezone_run(
-            {"location": self.new_york_point,
+            {"query": self.new_york_point,
              "at_time": datetime.utcfromtimestamp(0)},
             self.america_new_york,
         )
 
+    def test_timezone_at_time_normalization(self):
+        utc_naive_dt = datetime(2010, 1, 1, 0, 0, 0)
+        utc_timestamp = 1262304000
+        self.assertEqual(
+            utc_timestamp, self.geocoder._normalize_timezone_at_time(utc_naive_dt)
+        )
+
+        self.assertLess(
+            utc_timestamp, self.geocoder._normalize_timezone_at_time(None)
+        )
+
+        tz = timezone("Etc/GMT-2")
+        local_aware_dt = tz.localize(datetime(2010, 1, 1, 2, 0, 0))
+        self.assertEqual(
+            utc_timestamp, self.geocoder._normalize_timezone_at_time(local_aware_dt)
+        )
+
     def test_timezone_integer(self):
         """
         GoogleV3.timezone returns pytz object from epoch integer
         """
-        self.timezone_run(
-            {"location": self.new_york_point, "at_time": 0},
-            self.america_new_york,
-        )
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter('always')
+            self.timezone_run(
+                {"query": self.new_york_point, "at_time": 0},
+                self.america_new_york,
+            )
+            # at_time as number should issue a warning
+            self.assertLess(0, len(w))
 
     def test_timezone_no_date(self):
         """
         GoogleV3.timezone defaults `at_time`
         """
         self.timezone_run(
-            {"location": self.new_york_point},
+            {"query": self.new_york_point},
             self.america_new_york,
         )
 
@@ -227,7 +260,7 @@ class GoogleV3TestCase(GeocoderTestBase):
         GoogleV3.timezone invalid `at_time`
         """
         with self.assertRaises(exc.GeocoderQueryError):
-            self.geocoder.timezone(self.new_york_point, "eek")
+            self.geocoder.reverse_timezone(self.new_york_point, "eek")
 
     def test_geocode_bounds(self):
         """
diff --git a/test/test_timezone.py b/test/test_timezone.py
new file mode 100644
index 0000000..02662c9
--- /dev/null
+++ b/test/test_timezone.py
@@ -0,0 +1,66 @@
+import pickle
+import unittest
+
+import pytz
+import pytz.tzinfo
+
+from geopy.compat import u
+from geopy.timezone import Timezone, from_timezone_name
+
+
+class TimezoneTestCase(unittest.TestCase):
+
+    timezone_name = u("Europe/Moscow")
+
+    def test_create_from_timezone_name(self):
+        raw = dict(foo="bar")
+        tz = from_timezone_name(self.timezone_name, raw)
+
+        self.assertEqual(tz.raw['foo'], 'bar')
+        self.assertIsInstance(tz.pytz_timezone, pytz.tzinfo.BaseTzInfo)
+
+    def test_create_from_pytz_timezone(self):
+        pytz_timezone = pytz.timezone(self.timezone_name)
+        tz = Timezone(pytz_timezone)
+        self.assertIs(tz.pytz_timezone, pytz_timezone)
+
+    def test_string(self):
+        raw = dict(foo="bar")
+        tz = from_timezone_name(self.timezone_name, raw)
+        self.assertEqual(str(tz), self.timezone_name)
+
+    def test_repr(self):
+        raw = dict(foo="bar")
+        pytz_timezone = pytz.timezone(self.timezone_name)
+        tz = Timezone(pytz_timezone, raw)
+        self.assertEqual(repr(tz), "Timezone(%s)" % repr(pytz_timezone))
+
+    def test_eq(self):
+        tz = pytz.timezone("Europe/Paris")
+        raw1 = dict(a=1)
+        raw2 = dict(a=1)
+        self.assertEqual(Timezone(tz, raw1), Timezone(tz, raw2))
+
+    def test_ne(self):
+        tz1 = pytz.timezone("Europe/Paris")
+        tz2 = pytz.timezone("Europe/Prague")
+        raw = {}
+        self.assertNotEqual(Timezone(tz1, raw), Timezone(tz2, raw))
+
+    def test_picklable(self):
+        raw = dict(foo="bar")
+        tz = from_timezone_name(self.timezone_name, raw)
+        # https://docs.python.org/2/library/pickle.html#data-stream-format
+        for protocol in (0, 1, 2, -1):
+            pickled = pickle.dumps(tz, protocol=protocol)
+            tz_unp = pickle.loads(pickled)
+            self.assertEqual(tz, tz_unp)
+
+    def test_with_unpicklable_raw(self):
+        some_class = type('some_class', (object,), {})
+        raw_unpicklable = dict(missing=some_class())
+        del some_class
+        tz_unpicklable = from_timezone_name(self.timezone_name, raw_unpicklable)
+        for protocol in (0, 1, 2, -1):
+            with self.assertRaises((AttributeError, pickle.PicklingError)):
+                pickle.dumps(tz_unpicklable, protocol=protocol)