Repository: pganssle/zoneinfo

Issue:
Add support for fixed offset time zones
Zones like `UTC` are always one time zone and have no DST. I think traditionally zones with no DST return `None` instead of `timedelta(0)`, so we can do the same thing.

Not sure what to do about time zones like `Africa/Abidjan` that have been using `GMT` since 1912, and do have an LMT -> GMT transition but have no DST zones.

Discussion:

Patch:
diff --git a/lib/zoneinfo_module.c b/lib/zoneinfo_module.c
index d438a64..3647143 100644
--- a/lib/zoneinfo_module.c
+++ b/lib/zoneinfo_module.c
@@ -43,6 +43,7 @@ typedef struct {
     _ttinfo *ttinfo_before;
     _tzrule tzrule_after;
     _ttinfo *_ttinfos;  // Unique array of ttinfos for ease of deallocation
+    unsigned char fixed_offset;
     unsigned char source;
 } PyZoneInfo_ZoneInfo;
 
@@ -78,12 +79,15 @@ struct StrongCacheNode {
 
 static PyTypeObject PyZoneInfo_ZoneInfoType;
 
-// Constants
+// Globals
 static PyObject *TIMEDELTA_CACHE = NULL;
 static PyObject *ZONEINFO_WEAK_CACHE = NULL;
 static StrongCacheNode *ZONEINFO_STRONG_CACHE = NULL;
 static size_t ZONEINFO_STRONG_CACHE_MAX_SIZE = 8;
 
+static _ttinfo NO_TTINFO = {NULL, NULL, NULL, 0};
+
+// Constants
 static const int EPOCHORDINAL = 719163;
 static int DAYS_IN_MONTH[] = {
     -1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
@@ -132,6 +136,8 @@ static int
 build_ttinfo(long utcoffset, long dstoffset, PyObject *tzname, _ttinfo *out);
 static void
 xdecref_ttinfo(_ttinfo *ttinfo);
+static int
+ttinfo_eq(const _ttinfo *const tti0, const _ttinfo *const tti1);
 
 static int
 build_tzrule(PyObject *std_abbr, PyObject *dst_abbr, long std_offset,
@@ -784,6 +790,29 @@ xdecref_ttinfo(_ttinfo *ttinfo)
     }
 }
 
+/* Equality function for _ttinfo. */
+static int
+ttinfo_eq(const _ttinfo *const tti0, const _ttinfo *const tti1)
+{
+    int rv;
+    if ((rv = PyObject_RichCompareBool(tti0->utcoff, tti1->utcoff, Py_EQ)) <
+        1) {
+        goto end;
+    }
+
+    if ((rv = PyObject_RichCompareBool(tti0->dstoff, tti1->dstoff, Py_EQ)) <
+        1) {
+        goto end;
+    }
+
+    if ((rv = PyObject_RichCompareBool(tti0->tzname, tti1->tzname, Py_EQ)) <
+        1) {
+        goto end;
+    }
+end:
+    return rv;
+}
+
 /* Given a file-like object, this populates a ZoneInfo object
  *
  * The current version calls into a Python function to read the data from
@@ -1016,6 +1045,41 @@ load_data(PyZoneInfo_ZoneInfo *self, PyObject *file_obj)
         }
     }
 
+    // Determine if this is a "fixed offset" zone, meaning that the output of
+    // the utcoffset, dst and tzname functions does not depend on the specific
+    // datetime passed.
+    //
+    // We make three simplifying assumptions here:
+    //
+    // 1. If tzrule_after is not std_only, it has transitions that might occur
+    //    (it is possible to construct TZ strings that specify STD and DST but
+    //    no transitions ever occur, such as AAA0BBB,0/0,J365/25).
+    // 2. If self->_ttinfos contains more than one _ttinfo object, the objects
+    //    represent different offsets.
+    // 3. self->ttinfos contains no unused _ttinfos (in which case an otherwise
+    //    fixed-offset zone with extra _ttinfos defined may appear to *not* be
+    //    a fixed offset zone).
+    //
+    // Violations to these assumptions would be fairly exotic, and exotic
+    // zones should almost certainly not be used with datetime.time (the
+    // only thing that would be affected by this).
+    if (self->num_ttinfos > 1 || !self->tzrule_after.std_only) {
+        self->fixed_offset = 0;
+    }
+    else if (self->num_ttinfos == 0) {
+        self->fixed_offset = 1;
+    }
+    else {
+        int constant_offset =
+            ttinfo_eq(&(self->_ttinfos[0]), &self->tzrule_after.std);
+        if (constant_offset < 0) {
+            goto error;
+        }
+        else {
+            self->fixed_offset = constant_offset;
+        }
+    }
+
     int rv = 0;
     goto cleanup;
 error:
@@ -2039,6 +2103,17 @@ _bisect(const int64_t value, const int64_t *arr, size_t size)
 static _ttinfo *
 find_ttinfo(PyZoneInfo_ZoneInfo *self, PyObject *dt)
 {
+    // datetime.time has a .tzinfo attribute that passes None as the dt
+    // argument; it only really has meaning for fixed-offset zones.
+    if (dt == Py_None) {
+        if (self->fixed_offset) {
+            return &(self->tzrule_after.std);
+        }
+        else {
+            return &NO_TTINFO;
+        }
+    }
+
     int64_t ts;
     if (get_local_timestamp(dt, &ts)) {
         return NULL;
@@ -2493,6 +2568,8 @@ module_free()
     Py_XDECREF(io_open);
     io_open = NULL;
 
+    xdecref_ttinfo(&NO_TTINFO);
+
     Py_XDECREF(TIMEDELTA_CACHE);
     if (!Py_REFCNT(TIMEDELTA_CACHE)) {
         TIMEDELTA_CACHE = NULL;
@@ -2548,6 +2625,16 @@ zoneinfomodule_exec(PyObject *m)
         goto error;
     }
 
+    if (NO_TTINFO.utcoff == NULL) {
+        NO_TTINFO.utcoff = Py_None;
+        NO_TTINFO.dstoff = Py_None;
+        NO_TTINFO.tzname = Py_None;
+
+        for (size_t i = 0; i < 3; ++i) {
+            Py_INCREF(Py_None);
+        }
+    }
+
     if (initialize_caches()) {
         goto error;
     }
diff --git a/src/zoneinfo/_zoneinfo.py b/src/zoneinfo/_zoneinfo.py
index c9ef4ed..6b02df3 100644
--- a/src/zoneinfo/_zoneinfo.py
+++ b/src/zoneinfo/_zoneinfo.py
@@ -97,7 +97,6 @@ class ZoneInfo(tzinfo):
             cls._weak_cache.clear()
             cls._strong_cache.clear()
 
-    # TODO: Handle `datetime.time` in these calls
     def utcoffset(self, dt):
         return self._find_trans(dt).utcoff
 
@@ -152,6 +151,12 @@ class ZoneInfo(tzinfo):
             return dt
 
     def _find_trans(self, dt):
+        if dt is None:
+            if self._fixed_offset:
+                return self._tz_after
+            else:
+                return _NO_TTINFO
+
         ts = self._get_local_timestamp(dt)
 
         lt = self._trans_local[dt.fold]
@@ -259,9 +264,35 @@ class ZoneInfo(tzinfo):
             else:
                 self._tz_after = _ttinfo_list[-1]
 
+        # Determine if this is a "fixed offset" zone, meaning that the output
+        # of the utcoffset, dst and tzname functions does not depend on the
+        # specific datetime passed.
+        #
+        # We make three simplifying assumptions here:
+        #
+        # 1. If _tz_after is not a _ttinfo, it has transitions that might
+        #    actually occur (it is possible to construct TZ strings that
+        #    specify STD and DST but no transitions ever occur, such as
+        #    AAA0BBB,0/0,J365/25).
+        # 2. If _ttinfo_list contains more than one _ttinfo object, the objects
+        #    represent different offsets.
+        # 3. _ttinfo_list contains no unused _ttinfos (in which case an
+        #    otherwise fixed-offset zone with extra _ttinfos defined may
+        #    appear to *not* be a fixed offset zone).
+        #
+        # Violations to these assumptions would be fairly exotic, and exotic
+        # zones should almost certainly not be used with datetime.time (the
+        # only thing that would be affected by this).
+        if len(_ttinfo_list) > 1 or not isinstance(self._tz_after, _ttinfo):
+            self._fixed_offset = False
+        elif not _ttinfo_list:
+            self._fixed_offset = True
+        else:
+            self._fixed_offset = _ttinfo_list[0] == self._tz_after
+
     @staticmethod
     def _utcoff_to_dstoff(trans_idx, utcoffsets, isdsts):
-        # Now we must transform our ttis and abbrs into `__ttinfo` objects,
+        # Now we must transform our ttis and abbrs into `_ttinfo` objects,
         # but there is an issue: .dst() must return a timedelta with the
         # difference between utcoffset() and the "standard" offset, but
         # the "base offset" and "DST offset" are not encoded in the file;
@@ -365,6 +396,13 @@ class _ttinfo:
         self.dstoff = dstoff
         self.tzname = tzname
 
+    def __eq__(self, other):
+        return (
+            self.utcoff == other.utcoff
+            and self.dstoff == other.dstoff
+            and self.tzname == other.tzname
+        )
+
     def __repr__(self):  # pragma: nocover
         return (
             f"{self.__class__.__name__}"
@@ -372,6 +410,9 @@ class _ttinfo:
         )
 
 
+_NO_TTINFO = _ttinfo(None, None, None)
+
+
 class _TZStr:
     __slots__ = (
         "std",


Test patch:
diff --git a/tests/test_zoneinfo.py b/tests/test_zoneinfo.py
index 5af2e18..46564c0 100644
--- a/tests/test_zoneinfo.py
+++ b/tests/test_zoneinfo.py
@@ -149,6 +149,9 @@ class ZoneInfoTest(TzPathUserMixin, ZoneInfoTestBase):
     def zones(self):
         return ZoneDumpData.transition_keys()
 
+    def fixed_offset_zones(self):
+        return ZoneDumpData.fixed_offset_zones()
+
     def load_transition_examples(self, key):
         return ZoneDumpData.load_transition_examples(key)
 
@@ -248,6 +251,14 @@ class ZoneInfoTest(TzPathUserMixin, ZoneInfoTestBase):
                 with self.assertRaises(exc_type):
                     zone.fromutc(val)
 
+    def test_utc(self):
+        zi = self.klass("UTC")
+        dt = datetime(2020, 1, 1, tzinfo=zi)
+
+        self.assertEqual(dt.utcoffset(), ZERO)
+        self.assertEqual(dt.dst(), ZERO)
+        self.assertEqual(dt.tzname(), "UTC")
+
     def test_unambiguous(self):
         test_cases = []
         for key in self.zones():
@@ -351,6 +362,28 @@ class ZoneInfoTest(TzPathUserMixin, ZoneInfoTestBase):
                     dt_after = dt_after_utc.astimezone(zi)
                     self.assertEqual(dt_after.fold, 1, (dt_after, dt_utc))
 
+    def test_time_variable_offset(self):
+        # self.zones() only ever returns variable-offset zones
+        for key in self.zones():
+            zi = self.zone_from_key(key)
+            t = time(11, 15, 1, 34471, tzinfo=zi)
+
+            with self.subTest(key=key):
+                self.assertIs(t.tzname(), None)
+                self.assertIs(t.utcoffset(), None)
+                self.assertIs(t.dst(), None)
+
+    def test_time_fixed_offset(self):
+        for key, offset in self.fixed_offset_zones():
+            zi = self.zone_from_key(key)
+
+            t = time(11, 15, 1, 34471, tzinfo=zi)
+
+            with self.subTest(key=key):
+                self.assertEqual(t.tzname(), offset.tzname)
+                self.assertEqual(t.utcoffset(), offset.utcoffset)
+                self.assertEqual(t.dst(), offset.dst)
+
 
 class CZoneInfoTest(ZoneInfoTest):
     module = c_zoneinfo
@@ -593,6 +626,13 @@ class WeirdZoneTest(ZoneInfoTestBase):
                 self.assertEqual(dt.utcoffset(), offset.utcoffset)
                 self.assertEqual(dt.dst(), offset.dst)
 
+        # Test that offsets return None when using a datetime.time
+        t = time(0, tzinfo=zi)
+        with self.subTest("Testing datetime.time"):
+            self.assertIs(t.tzname(), None)
+            self.assertIs(t.utcoffset(), None)
+            self.assertIs(t.dst(), None)
+
     def test_tz_before_only(self):
         # From RFC 8536 Section 3.2:
         #
@@ -696,6 +736,27 @@ class WeirdZoneTest(ZoneInfoTestBase):
             self.assertEqual(dt_utc.astimezone(zi), dt)
             self.assertEqual(dt, dt_utc)
 
+    def test_fixed_offset_phantom_transition(self):
+        UTC = ZoneOffset("UTC", ZERO, ZERO)
+
+        transitions = [ZoneTransition(datetime(1970, 1, 1), UTC, UTC)]
+
+        after = "UTC0"
+        zf = self.construct_zone(transitions, after)
+        zi = self.klass.from_file(zf, key="UTC")
+
+        dt = datetime(2020, 1, 1, tzinfo=zi)
+        with self.subTest("datetime.datetime"):
+            self.assertEqual(dt.tzname(), UTC.tzname)
+            self.assertEqual(dt.utcoffset(), UTC.utcoffset)
+            self.assertEqual(dt.dst(), UTC.dst)
+
+        t = time(0, tzinfo=zi)
+        with self.subTest("datetime.time"):
+            self.assertEqual(t.tzname(), UTC.tzname)
+            self.assertEqual(t.utcoffset(), UTC.utcoffset)
+            self.assertEqual(t.dst(), UTC.dst)
+
     def construct_zone(self, transitions, after=None, version=3):
         # These are not used for anything, so we're not going to include
         # them for now.
@@ -1618,15 +1679,25 @@ class ZoneDumpData:
     def load_transition_examples(cls, key):
         return cls._get_zonedump()[key]
 
-    # These are examples of a bunch of transitions that can be used in tests
-    # The format for each transition is:
-    #
+    @classmethod
+    def fixed_offset_zones(cls):
+        if not cls._FIXED_OFFSET_ZONES:
+            cls._populate_fixed_offsets()
+
+        return cls._FIXED_OFFSET_ZONES.items()
+
     @classmethod
     def _get_zonedump(cls):
         if not cls._ZONEDUMP_DATA:
             cls._populate_zonedump_data()
         return cls._ZONEDUMP_DATA
 
+    @classmethod
+    def _populate_fixed_offsets(cls):
+        cls._FIXED_OFFSET_ZONES = {
+            "UTC": ZoneOffset("UTC", ZERO, ZERO),
+        }
+
     @classmethod
     def _populate_zonedump_data(cls):
         def _Africa_Abidjan():
@@ -1799,4 +1870,5 @@ class ZoneDumpData:
             "Pacific/Kiritimati": _Pacific_Kiritimati(),
         }
 
-    _ZONEDUMP_DATA = {}
+    _ZONEDUMP_DATA = None
+    _FIXED_OFFSET_ZONES = None
diff --git a/tests/test_zoneinfo_property.py b/tests/test_zoneinfo_property.py
index 6e38134..030d4c4 100644
--- a/tests/test_zoneinfo_property.py
+++ b/tests/test_zoneinfo_property.py
@@ -17,6 +17,7 @@ py_zoneinfo, c_zoneinfo = test_support.get_modules()
 UTC = datetime.timezone.utc
 MIN_UTC = datetime.datetime.min.replace(tzinfo=UTC)
 MAX_UTC = datetime.datetime.max.replace(tzinfo=UTC)
+ZERO = datetime.timedelta(0)
 
 
 def _valid_keys():
@@ -83,6 +84,19 @@ class ZoneInfoTest(ZoneInfoTestBase):
         zi = self.klass(key)
         self.assertEqual(str(zi), key)
 
+    @hypothesis.given(
+        dt=hypothesis.strategies.one_of(
+            hypothesis.strategies.datetimes(), hypothesis.strategies.times()
+        )
+    )
+    def test_utc(self, dt):
+        zi = self.klass("UTC")
+        dt_zi = dt.replace(tzinfo=zi)
+
+        self.assertEqual(dt_zi.utcoffset(), ZERO)
+        self.assertEqual(dt_zi.dst(), ZERO)
+        self.assertEqual(dt_zi.tzname(), "UTC")
+
 
 class CZoneInfoTest(ZoneInfoTest):
     module = c_zoneinfo