A new, and much hairier, implementation of astimezone(), building on
authorTim Peters <tim.peters@gmail.com>
Tue, 31 Dec 2002 17:36:56 +0000 (17:36 +0000)
committerTim Peters <tim.peters@gmail.com>
Tue, 31 Dec 2002 17:36:56 +0000 (17:36 +0000)
an idea from Guido.  This restores that the datetime implementation
never passes a datetime d to a tzinfo method unless d.tzinfo is the
tzinfo instance whose method is being called.  That in turn allows
enormous simplifications in user-written tzinfo classes (see the Python
sandbox US.py and EU.py for fully fleshed-out examples).

d.astimezone(tz) also raises ValueError now if d lands in the one hour
of the year that can't be expressed in tz (this can happen iff tz models
both standard and daylight time).  That it used to return a nonsense
result always ate at me, and it turned out that it seemed impossible to
force a consistent nonsense result under the new implementation (which
doesn't know anything about how tzinfo classes implement their methods --
it can only infer properties indirectly).  Guido doesn't like this --
expect it to change.

New tests of conversion between adjacent DST-aware timezones don't pass
yet, and are commented out.

Running the datetime tests in a loop under a debug build leaks 9
references per test run, but I don't believe the datetime code is the
cause (it didn't leak the last time I changed the C code, and the leak
is the same if I disable all the tests that invoke the only function
that changed here).  I'll pursue that next.

Lib/test/test_datetime.py
Modules/datetimemodule.c

index 41ceae7d26752747712e5d776cacf9e3ddf479a7..3ef077293b01a95483da5c792546c7ed963a3dbb 100644 (file)
@@ -2560,16 +2560,7 @@ class USTimeZone(tzinfo):
             # An exception instead may be sensible here, in one or more of
             # the cases.
             return ZERO
-
-        convert_endpoints_to_utc = False
-        if dt.tzinfo is not self:
-            # Convert dt to UTC.
-            offset = dt.utcoffset()
-            if offset is None:
-                # Again, an exception instead may be sensible.
-                return ZERO
-            convert_endpoints_to_utc = True
-            dt -= offset
+        assert dt.tzinfo is self
 
         # Find first Sunday in April.
         start = first_sunday_on_or_after(DSTSTART.replace(year=dt.year))
@@ -2579,10 +2570,6 @@ class USTimeZone(tzinfo):
         end = first_sunday_on_or_after(DSTEND.replace(year=dt.year))
         assert end.weekday() == 6 and end.month == 10 and end.day >= 25
 
-        if convert_endpoints_to_utc:
-            start -= self.stdoffset    # start is in std time
-            end -= self.stdoffset + HOUR # end is in DST time
-
         # Can't compare naive to aware objects, so strip the timezone from
         # dt first.
         if start <= dt.astimezone(None) < end:
@@ -2590,8 +2577,10 @@ class USTimeZone(tzinfo):
         else:
             return ZERO
 
-Eastern = USTimeZone(-5, "Eastern", "EST", "EDT")
-Pacific = USTimeZone(-8, "Pacific", "PST", "PDT")
+Eastern  = USTimeZone(-5, "Eastern",  "EST", "EDT")
+Central  = USTimeZone(-6, "Central",  "CST", "CDT")
+Mountain = USTimeZone(-7, "Mountain", "MST", "MDT")
+Pacific  = USTimeZone(-8, "Pacific",  "PST", "PDT")
 utc_real = FixedOffset(0, "UTC", 0)
 # For better test coverage, we want another flavor of UTC that's west of
 # the Eastern and Pacific timezones.
@@ -2602,6 +2591,78 @@ class TestTimezoneConversions(unittest.TestCase):
     dston = datetimetz(2002, 4, 7, 2)
     dstoff = datetimetz(2002, 10, 27, 2)
 
+
+    # Check a time that's inside DST.
+    def checkinside(self, dt, tz, utc, dston, dstoff):
+        self.assertEqual(dt.dst(), HOUR)
+
+        # Conversion to our own timezone is always an identity.
+        self.assertEqual(dt.astimezone(tz), dt)
+        # Conversion to None is always the same as stripping tzinfo.
+        self.assertEqual(dt.astimezone(None), dt.replace(tzinfo=None))
+
+        asutc = dt.astimezone(utc)
+        there_and_back = asutc.astimezone(tz)
+
+        # Conversion to UTC and back isn't always an identity here,
+        # because there are redundant spellings (in local time) of
+        # UTC time when DST begins:  the clock jumps from 1:59:59
+        # to 3:00:00, and a local time of 2:MM:SS doesn't really
+        # make sense then.  The classes above treat 2:MM:SS as
+        # daylight time then (it's "after 2am"), really an alias
+        # for 1:MM:SS standard time.  The latter form is what
+        # conversion back from UTC produces.
+        if dt.date() == dston.date() and dt.hour == 2:
+            # We're in the redundant hour, and coming back from
+            # UTC gives the 1:MM:SS standard-time spelling.
+            self.assertEqual(there_and_back + HOUR, dt)
+            # Although during was considered to be in daylight
+            # time, there_and_back is not.
+            self.assertEqual(there_and_back.dst(), ZERO)
+            # They're the same times in UTC.
+            self.assertEqual(there_and_back.astimezone(utc),
+                             dt.astimezone(utc))
+        else:
+            # We're not in the redundant hour.
+            self.assertEqual(dt, there_and_back)
+
+        # Because we have a redundant spelling when DST begins,
+        # there is (unforunately) an hour when DST ends that can't
+        # be spelled at all in local time.  When DST ends, the
+        # clock jumps from 1:59:59 back to 1:00:00 again.  The
+        # hour beginning then has no spelling in local time:
+        # 1:MM:SS is taken to be daylight time, and 2:MM:SS as
+        # standard time.  The hour 1:MM:SS standard time ==
+        # 2:MM:SS daylight time can't be expressed in local time.
+        nexthour_utc = asutc + HOUR
+        if dt.date() == dstoff.date() and dt.hour == 1:
+            # We're in the hour before DST ends.  The hour after
+            # is ineffable.
+            # For concreteness, picture Eastern.  during is of
+            # the form 1:MM:SS, it's daylight time, so that's
+            # 5:MM:SS UTC.  Adding an hour gives 6:MM:SS UTC.
+            # Daylight time ended at 2+4 == 6:00:00 UTC, so
+            # 6:MM:SS is (correctly) taken to be standard time.
+            # But standard time is at offset -5, and that maps
+            # right back to the 1:MM:SS Eastern we started with.
+            # That's correct, too, *if* 1:MM:SS were taken as
+            # being standard time.  But it's not -- on this day
+            # it's taken as daylight time.
+            self.assertRaises(ValueError,
+                              nexthour_utc.astimezone, tz)
+        else:
+            nexthour_tz = nexthour_utc.astimezone(utc)
+            self.assertEqual(nexthour_tz - dt, HOUR)
+
+    # Check a time that's outside DST.
+    def checkoutside(self, dt, tz, utc):
+        self.assertEqual(dt.dst(), ZERO)
+
+        # Conversion to our own timezone is always an identity.
+        self.assertEqual(dt.astimezone(tz), dt)
+        # Conversion to None is always the same as stripping tzinfo.
+        self.assertEqual(dt.astimezone(None), dt.replace(tzinfo=None))
+
     def convert_between_tz_and_utc(self, tz, utc):
         dston = self.dston.replace(tzinfo=tz)
         dstoff = self.dstoff.replace(tzinfo=tz)
@@ -2611,77 +2672,13 @@ class TestTimezoneConversions(unittest.TestCase):
                       timedelta(minutes=1),
                       timedelta(microseconds=1)):
 
-            for during in dston, dston + delta, dstoff - delta:
-                self.assertEqual(during.dst(), HOUR)
-
-                # Conversion to our own timezone is always an identity.
-                self.assertEqual(during.astimezone(tz), during)
-                # Conversion to None is always the same as stripping tzinfo.
-                self.assertEqual(during.astimezone(None),
-                                 during.replace(tzinfo=None))
-
-                asutc = during.astimezone(utc)
-                there_and_back = asutc.astimezone(tz)
-
-                # Conversion to UTC and back isn't always an identity here,
-                # because there are redundant spellings (in local time) of
-                # UTC time when DST begins:  the clock jumps from 1:59:59
-                # to 3:00:00, and a local time of 2:MM:SS doesn't really
-                # make sense then.  The classes above treat 2:MM:SS as
-                # daylight time then (it's "after 2am"), really an alias
-                # for 1:MM:SS standard time.  The latter form is what
-                # conversion back from UTC produces.
-                if during.date() == dston.date() and during.hour == 2:
-                    # We're in the redundant hour, and coming back from
-                    # UTC gives the 1:MM:SS standard-time spelling.
-                    self.assertEqual(there_and_back + HOUR, during)
-                    # Although during was considered to be in daylight
-                    # time, there_and_back is not.
-                    self.assertEqual(there_and_back.dst(), ZERO)
-                    # They're the same times in UTC.
-                    self.assertEqual(there_and_back.astimezone(utc),
-                                     during.astimezone(utc))
-                else:
-                    # We're not in the redundant hour.
-                    self.assertEqual(during, there_and_back)
-
-                # Because we have a redundant spelling when DST begins,
-                # there is (unforunately) an hour when DST ends that can't
-                # be spelled at all in local time.  When DST ends, the
-                # clock jumps from 1:59:59 back to 1:00:00 again.  The
-                # hour beginning then has no spelling in local time:
-                # 1:MM:SS is taken to be daylight time, and 2:MM:SS as
-                # standard time.  The hour 1:MM:SS standard time ==
-                # 2:MM:SS daylight time can't be expressed in local time.
-                nexthour_utc = asutc + HOUR
-                nexthour_tz = nexthour_utc.astimezone(tz)
-                if during.date() == dstoff.date() and during.hour == 1:
-                    # We're in the hour before DST ends.  The hour after
-                    # is ineffable.
-                    # For concreteness, picture Eastern.  during is of
-                    # the form 1:MM:SS, it's daylight time, so that's
-                    # 5:MM:SS UTC.  Adding an hour gives 6:MM:SS UTC.
-                    # Daylight time ended at 2+4 == 6:00:00 UTC, so
-                    # 6:MM:SS is (correctly) taken to be standard time.
-                    # But standard time is at offset -5, and that maps
-                    # right back to the 1:MM:SS Eastern we started with.
-                    # That's correct, too, *if* 1:MM:SS were taken as
-                    # being standard time.  But it's not -- on this day
-                    # it's taken as daylight time.
-                    self.assertEqual(during, nexthour_tz)
-                else:
-                    self.assertEqual(nexthour_tz - during, HOUR)
-
-            for outside in dston - delta, dstoff, dstoff + delta:
-                self.assertEqual(outside.dst(), ZERO)
-                there_and_back = outside.astimezone(utc).astimezone(tz)
-                self.assertEqual(outside, there_and_back)
+            self.checkinside(dston, tz, utc, dston, dstoff)
+            for during in dston + delta, dstoff - delta:
+                self.checkinside(during, tz, utc, dston, dstoff)
 
-                # Conversion to our own timezone is always an identity.
-                self.assertEqual(outside.astimezone(tz), outside)
-                # Conversion to None is always the same as stripping tzinfo.
-                self.assertEqual(outside.astimezone(None),
-                                 outside.replace(tzinfo=None))
+            self.checkoutside(dstoff, tz, utc)
+            for outside in dston - delta, dstoff + delta:
+                self.checkoutside(outside, tz, utc)
 
     def test_easy(self):
         # Despite the name of this test, the endcases are excruciating.
@@ -2694,6 +2691,9 @@ class TestTimezoneConversions(unittest.TestCase):
         # hours" don't overlap.
         self.convert_between_tz_and_utc(Eastern, Pacific)
         self.convert_between_tz_and_utc(Pacific, Eastern)
+        # XXX These fail!
+        #self.convert_between_tz_and_utc(Eastern, Central)
+        #self.convert_between_tz_and_utc(Central, Eastern)
 
 
 def test_suite():
index 48445a104a4b415d1f97490163cabc17097f74c4..40f4773447af754acbee26721639cdfc309d68d6 100644 (file)
@@ -4751,6 +4751,11 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args,
        int ss = DATE_GET_SECOND(self);
        int us = DATE_GET_MICROSECOND(self);
 
+       PyObject *result;
+       PyObject *temp;
+       int myoff, otoff, newoff;
+       int none;
+
        PyObject *tzinfo;
        static char *keywords[] = {"tz", NULL};
 
@@ -4760,30 +4765,127 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args,
        if (check_tzinfo_subclass(tzinfo) < 0)
                return NULL;
 
-       if (tzinfo != Py_None && self->tzinfo != Py_None) {
-               int none;
-               int selfoffset;
-               selfoffset = call_utcoffset(self->tzinfo,
-                                           (PyObject *)self,
-                                           &none);
-               if (selfoffset == -1 && PyErr_Occurred())
-                       return NULL;
-               if (! none) {
-                       int tzoffset;
-                       tzoffset = call_utcoffset(tzinfo,
-                                                 (PyObject *)self,
-                                                 &none);
-                       if (tzoffset == -1 && PyErr_Occurred())
-                               return NULL;
-                       if (! none) {
-                               mm -= selfoffset - tzoffset;
-                               if (normalize_datetime(&y, &m, &d,
-                                                      &hh, &mm, &ss, &us) < 0)
-                                       return NULL;
-                       }
-               }
-       }
-       return new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo);
+        /* Don't call utcoffset unless necessary. */
+       result = new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo);
+       if (result == NULL ||
+           tzinfo == Py_None ||
+           self->tzinfo == Py_None ||
+           self->tzinfo == tzinfo)
+               return result;
+
+        /* Get the offsets.  If either object turns out to be naive, again
+         * there's no conversion of date or time fields.
+         */
+       myoff = call_utcoffset(self->tzinfo, (PyObject *)self, &none);
+       if (myoff == -1 && PyErr_Occurred())
+               goto Fail;
+       if (none)
+               return result;
+
+       otoff = call_utcoffset(tzinfo, result, &none);
+       if (otoff == -1 && PyErr_Occurred())
+               goto Fail;
+       if (none)
+               return result;
+
+       /* Add otoff-myoff to result. */
+       mm += otoff - myoff;
+       if (normalize_datetime(&y, &m, &d, &hh, &mm, &ss, &us) < 0)
+               goto Fail;
+       temp = new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo);
+       if (temp == NULL)
+               goto Fail;
+       Py_DECREF(result);
+       result = temp;
+
+       /* If tz is a fixed-offset class, we're done, but we can't know
+        * whether it is.  If it's a DST-aware class, and we're not near a
+        * DST boundary, we're also done.  If we crossed a DST boundary,
+        * the offset will be different now, and that's our only clue.
+        * Unfortunately, we can be in trouble even if we didn't cross a
+        * DST boundary, if we landed on one of the DST "problem hours".
+        */
+       newoff = call_utcoffset(tzinfo, result, &none);
+       if (newoff == -1 && PyErr_Occurred())
+               goto Fail;
+       if (none)
+               goto Inconsistent;
+
+       if (newoff != otoff) {
+               /* We did cross a boundary.  Try to correct. */
+               mm += newoff - otoff;
+               if (normalize_datetime(&y, &m, &d, &hh, &mm, &ss, &us) < 0)
+                       goto Fail;
+               temp = new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo);
+               if (temp == NULL)
+                       goto Fail;
+               Py_DECREF(result);
+               result = temp;
+
+               otoff = call_utcoffset(tzinfo, result, &none);
+               if (otoff == -1 && PyErr_Occurred())
+                       goto Fail;
+               if (none)
+                       goto Inconsistent;
+        }
+       /* If this is the first hour of DST, it may be a local time that
+        * doesn't make sense on the local clock, in which case the naive
+        * hour before it (in standard time) is equivalent and does make
+        * sense on the local clock.  So force that.
+        */
+       hh -= 1;
+       if (normalize_datetime(&y, &m, &d, &hh, &mm, &ss, &us) < 0)
+               goto Fail;
+       temp = new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo);
+       if (temp == NULL)
+               goto Fail;
+       newoff = call_utcoffset(tzinfo, temp, &none);
+       if (newoff == -1 && PyErr_Occurred()) {
+               Py_DECREF(temp);
+               goto Fail;
+       }
+       if (none) {
+               Py_DECREF(temp);
+               goto Inconsistent;
+       }
+       /* Are temp and result really the same time?  temp == result iff
+        * temp - newoff == result - otoff, iff
+        * (result - HOUR) - newoff = result - otoff, iff
+        * otoff - newoff == HOUR
+        */
+       if (otoff - newoff == 60) {
+               /* use the local time that makes sense */
+               Py_DECREF(result);
+               return temp;
+       }
+       Py_DECREF(temp);
+
+       /* There's still a problem with the unspellable (in local time)
+        * hour after DST ends.
+        */
+       temp = datetime_richcompare((PyDateTime_DateTime *)self,
+                                   result, Py_EQ);
+       if (temp == NULL)
+               goto Fail;
+       if (temp == Py_True) {
+               Py_DECREF(temp);
+               return result;
+       }
+       Py_DECREF(temp);
+        /* Else there's no way to spell self in zone other.tz. */
+        PyErr_SetString(PyExc_ValueError, "astimezone(): the source "
+                       "datetimetz can't be expressed in the target "
+                       "timezone's local time");
+        goto Fail;
+
+Inconsistent:
+       PyErr_SetString(PyExc_ValueError, "astimezone(): tz.utcoffset() "
+                       "gave inconsistent results; cannot convert");
+
+       /* fall thru to failure */
+Fail:
+       Py_DECREF(result);
+       return NULL;
 }
 
 static PyObject *