Commit 521fc15e authored by Tim Peters's avatar Tim Peters

A new, and much hairier, implementation of astimezone(), building on

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.
parent ba2f875d
...@@ -2560,16 +2560,7 @@ class USTimeZone(tzinfo): ...@@ -2560,16 +2560,7 @@ class USTimeZone(tzinfo):
# An exception instead may be sensible here, in one or more of # An exception instead may be sensible here, in one or more of
# the cases. # the cases.
return ZERO return ZERO
assert dt.tzinfo is self
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
# Find first Sunday in April. # Find first Sunday in April.
start = first_sunday_on_or_after(DSTSTART.replace(year=dt.year)) start = first_sunday_on_or_after(DSTSTART.replace(year=dt.year))
...@@ -2579,10 +2570,6 @@ class USTimeZone(tzinfo): ...@@ -2579,10 +2570,6 @@ class USTimeZone(tzinfo):
end = first_sunday_on_or_after(DSTEND.replace(year=dt.year)) end = first_sunday_on_or_after(DSTEND.replace(year=dt.year))
assert end.weekday() == 6 and end.month == 10 and end.day >= 25 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 # Can't compare naive to aware objects, so strip the timezone from
# dt first. # dt first.
if start <= dt.astimezone(None) < end: if start <= dt.astimezone(None) < end:
...@@ -2591,6 +2578,8 @@ class USTimeZone(tzinfo): ...@@ -2591,6 +2578,8 @@ class USTimeZone(tzinfo):
return ZERO return ZERO
Eastern = USTimeZone(-5, "Eastern", "EST", "EDT") Eastern = USTimeZone(-5, "Eastern", "EST", "EDT")
Central = USTimeZone(-6, "Central", "CST", "CDT")
Mountain = USTimeZone(-7, "Mountain", "MST", "MDT")
Pacific = USTimeZone(-8, "Pacific", "PST", "PDT") Pacific = USTimeZone(-8, "Pacific", "PST", "PDT")
utc_real = FixedOffset(0, "UTC", 0) utc_real = FixedOffset(0, "UTC", 0)
# For better test coverage, we want another flavor of UTC that's west of # For better test coverage, we want another flavor of UTC that's west of
...@@ -2602,25 +2591,17 @@ class TestTimezoneConversions(unittest.TestCase): ...@@ -2602,25 +2591,17 @@ class TestTimezoneConversions(unittest.TestCase):
dston = datetimetz(2002, 4, 7, 2) dston = datetimetz(2002, 4, 7, 2)
dstoff = datetimetz(2002, 10, 27, 2) dstoff = datetimetz(2002, 10, 27, 2)
def convert_between_tz_and_utc(self, tz, utc):
dston = self.dston.replace(tzinfo=tz)
dstoff = self.dstoff.replace(tzinfo=tz)
for delta in (timedelta(weeks=13),
DAY,
HOUR,
timedelta(minutes=1),
timedelta(microseconds=1)):
for during in dston, dston + delta, dstoff - delta: # Check a time that's inside DST.
self.assertEqual(during.dst(), HOUR) def checkinside(self, dt, tz, utc, dston, dstoff):
self.assertEqual(dt.dst(), HOUR)
# Conversion to our own timezone is always an identity. # Conversion to our own timezone is always an identity.
self.assertEqual(during.astimezone(tz), during) self.assertEqual(dt.astimezone(tz), dt)
# Conversion to None is always the same as stripping tzinfo. # Conversion to None is always the same as stripping tzinfo.
self.assertEqual(during.astimezone(None), self.assertEqual(dt.astimezone(None), dt.replace(tzinfo=None))
during.replace(tzinfo=None))
asutc = during.astimezone(utc) asutc = dt.astimezone(utc)
there_and_back = asutc.astimezone(tz) there_and_back = asutc.astimezone(tz)
# Conversion to UTC and back isn't always an identity here, # Conversion to UTC and back isn't always an identity here,
...@@ -2631,19 +2612,19 @@ class TestTimezoneConversions(unittest.TestCase): ...@@ -2631,19 +2612,19 @@ class TestTimezoneConversions(unittest.TestCase):
# daylight time then (it's "after 2am"), really an alias # daylight time then (it's "after 2am"), really an alias
# for 1:MM:SS standard time. The latter form is what # for 1:MM:SS standard time. The latter form is what
# conversion back from UTC produces. # conversion back from UTC produces.
if during.date() == dston.date() and during.hour == 2: if dt.date() == dston.date() and dt.hour == 2:
# We're in the redundant hour, and coming back from # We're in the redundant hour, and coming back from
# UTC gives the 1:MM:SS standard-time spelling. # UTC gives the 1:MM:SS standard-time spelling.
self.assertEqual(there_and_back + HOUR, during) self.assertEqual(there_and_back + HOUR, dt)
# Although during was considered to be in daylight # Although during was considered to be in daylight
# time, there_and_back is not. # time, there_and_back is not.
self.assertEqual(there_and_back.dst(), ZERO) self.assertEqual(there_and_back.dst(), ZERO)
# They're the same times in UTC. # They're the same times in UTC.
self.assertEqual(there_and_back.astimezone(utc), self.assertEqual(there_and_back.astimezone(utc),
during.astimezone(utc)) dt.astimezone(utc))
else: else:
# We're not in the redundant hour. # We're not in the redundant hour.
self.assertEqual(during, there_and_back) self.assertEqual(dt, there_and_back)
# Because we have a redundant spelling when DST begins, # Because we have a redundant spelling when DST begins,
# there is (unforunately) an hour when DST ends that can't # there is (unforunately) an hour when DST ends that can't
...@@ -2654,8 +2635,7 @@ class TestTimezoneConversions(unittest.TestCase): ...@@ -2654,8 +2635,7 @@ class TestTimezoneConversions(unittest.TestCase):
# standard time. The hour 1:MM:SS standard time == # standard time. The hour 1:MM:SS standard time ==
# 2:MM:SS daylight time can't be expressed in local time. # 2:MM:SS daylight time can't be expressed in local time.
nexthour_utc = asutc + HOUR nexthour_utc = asutc + HOUR
nexthour_tz = nexthour_utc.astimezone(tz) if dt.date() == dstoff.date() and dt.hour == 1:
if during.date() == dstoff.date() and during.hour == 1:
# We're in the hour before DST ends. The hour after # We're in the hour before DST ends. The hour after
# is ineffable. # is ineffable.
# For concreteness, picture Eastern. during is of # For concreteness, picture Eastern. during is of
...@@ -2668,20 +2648,37 @@ class TestTimezoneConversions(unittest.TestCase): ...@@ -2668,20 +2648,37 @@ class TestTimezoneConversions(unittest.TestCase):
# That's correct, too, *if* 1:MM:SS were taken as # That's correct, too, *if* 1:MM:SS were taken as
# being standard time. But it's not -- on this day # being standard time. But it's not -- on this day
# it's taken as daylight time. # it's taken as daylight time.
self.assertEqual(during, nexthour_tz) self.assertRaises(ValueError,
nexthour_utc.astimezone, tz)
else: else:
self.assertEqual(nexthour_tz - during, HOUR) nexthour_tz = nexthour_utc.astimezone(utc)
self.assertEqual(nexthour_tz - dt, HOUR)
for outside in dston - delta, dstoff, dstoff + delta: # Check a time that's outside DST.
self.assertEqual(outside.dst(), ZERO) def checkoutside(self, dt, tz, utc):
there_and_back = outside.astimezone(utc).astimezone(tz) self.assertEqual(dt.dst(), ZERO)
self.assertEqual(outside, there_and_back)
# Conversion to our own timezone is always an identity. # Conversion to our own timezone is always an identity.
self.assertEqual(outside.astimezone(tz), outside) self.assertEqual(dt.astimezone(tz), dt)
# Conversion to None is always the same as stripping tzinfo. # Conversion to None is always the same as stripping tzinfo.
self.assertEqual(outside.astimezone(None), self.assertEqual(dt.astimezone(None), dt.replace(tzinfo=None))
outside.replace(tzinfo=None))
def convert_between_tz_and_utc(self, tz, utc):
dston = self.dston.replace(tzinfo=tz)
dstoff = self.dstoff.replace(tzinfo=tz)
for delta in (timedelta(weeks=13),
DAY,
HOUR,
timedelta(minutes=1),
timedelta(microseconds=1)):
self.checkinside(dston, tz, utc, dston, dstoff)
for during in dston + delta, dstoff - delta:
self.checkinside(during, tz, utc, dston, dstoff)
self.checkoutside(dstoff, tz, utc)
for outside in dston - delta, dstoff + delta:
self.checkoutside(outside, tz, utc)
def test_easy(self): def test_easy(self):
# Despite the name of this test, the endcases are excruciating. # Despite the name of this test, the endcases are excruciating.
...@@ -2694,6 +2691,9 @@ class TestTimezoneConversions(unittest.TestCase): ...@@ -2694,6 +2691,9 @@ class TestTimezoneConversions(unittest.TestCase):
# hours" don't overlap. # hours" don't overlap.
self.convert_between_tz_and_utc(Eastern, Pacific) self.convert_between_tz_and_utc(Eastern, Pacific)
self.convert_between_tz_and_utc(Pacific, Eastern) 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(): def test_suite():
......
...@@ -4751,6 +4751,11 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args, ...@@ -4751,6 +4751,11 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args,
int ss = DATE_GET_SECOND(self); int ss = DATE_GET_SECOND(self);
int us = DATE_GET_MICROSECOND(self); int us = DATE_GET_MICROSECOND(self);
PyObject *result;
PyObject *temp;
int myoff, otoff, newoff;
int none;
PyObject *tzinfo; PyObject *tzinfo;
static char *keywords[] = {"tz", NULL}; static char *keywords[] = {"tz", NULL};
...@@ -4760,30 +4765,127 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args, ...@@ -4760,30 +4765,127 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args,
if (check_tzinfo_subclass(tzinfo) < 0) if (check_tzinfo_subclass(tzinfo) < 0)
return NULL; return NULL;
if (tzinfo != Py_None && self->tzinfo != Py_None) { /* Don't call utcoffset unless necessary. */
int none; result = new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo);
int selfoffset; if (result == NULL ||
selfoffset = call_utcoffset(self->tzinfo, tzinfo == Py_None ||
(PyObject *)self, self->tzinfo == Py_None ||
&none); self->tzinfo == tzinfo)
if (selfoffset == -1 && PyErr_Occurred()) return result;
return NULL;
if (! none) { /* Get the offsets. If either object turns out to be naive, again
int tzoffset; * there's no conversion of date or time fields.
tzoffset = call_utcoffset(tzinfo, */
(PyObject *)self, myoff = call_utcoffset(self->tzinfo, (PyObject *)self, &none);
&none); if (myoff == -1 && PyErr_Occurred())
if (tzoffset == -1 && PyErr_Occurred()) goto Fail;
return NULL; if (none)
if (! none) { return result;
mm -= selfoffset - tzoffset;
if (normalize_datetime(&y, &m, &d, otoff = call_utcoffset(tzinfo, result, &none);
&hh, &mm, &ss, &us) < 0) if (otoff == -1 && PyErr_Occurred())
return NULL; 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;
} }
return new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo); 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 * static PyObject *
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment