Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit adf6420

Browse files
committed
A new implementation of astimezone() that does what we agreed on in all
cases, plus even tougher tests of that. This implementation follows the correctness proof very closely, and should also be quicker (yes, I wrote the proof before the code, and the code proves the proof <wink>).
1 parent 8ec7881 commit adf6420

5 files changed

Lines changed: 191 additions & 65 deletions

File tree

Doc/lib/libdatetime.tex

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -924,11 +924,11 @@ \subsection{\class{tzinfo} Objects \label{datetime-tzinfo}}
924924
\code{tz.utcoffset(dt) - tz.dst(dt)}
925925

926926
must return the same result for every \class{datetimetz} \var{dt}
927-
in a given year with \code{dt.tzinfo==tz} For sane \class{tzinfo}
928-
subclasses, this expression yields the time zone's "standard offset"
929-
within the year, which should be the same across all days in the year.
930-
The implementation of \method{datetimetz.astimezone()} relies on this,
931-
but cannot detect violations; it's the programmer's responsibility to
927+
with \code{dt.tzinfo==tz} For sane \class{tzinfo} subclasses, this
928+
expression yields the time zone's "standard offset", which should not
929+
depend on the date or the time, but only on geographic location. The
930+
implementation of \method{datetimetz.astimezone()} relies on this, but
931+
cannot detect violations; it's the programmer's responsibility to
932932
ensure it.
933933

934934
\begin{methoddesc}{tzname}{self, dt}
@@ -970,6 +970,50 @@ \subsection{\class{tzinfo} Objects \label{datetime-tzinfo}}
970970

971971
\verbatiminput{tzinfo-examples.py}
972972

973+
Note that there are unavoidable subtleties twice per year in a tzinfo
974+
subclass accounting for both standard and daylight time, at the DST
975+
transition points. For concreteness, consider US Eastern (UTC -0500),
976+
where EDT begins the minute after 1:59 (EST) on the first Sunday in
977+
April, and ends the minute after 1:59 (EDT) on the last Sunday in October:
978+
979+
\begin{verbatim}
980+
UTC 3:MM 4:MM 5:MM 6:MM 7:MM 8:MM
981+
EST 22:MM 23:MM 0:MM 1:MM 2:MM 3:MM
982+
EDT 23:MM 0:MM 1:MM 2:MM 3:MM 4:MM
983+
984+
start 22:MM 23:MM 0:MM 1:MM 3:MM 4:MM
985+
986+
end 23:MM 0:MM 1:MM 1:MM 2:MM 3:MM
987+
\end{verbatim}
988+
989+
When DST starts (the "start" line), the local wall clock leaps from 1:59
990+
to 3:00. A wall time of the form 2:MM doesn't really make sense on that
991+
day, so astimezone(Eastern) won't deliver a result with hour=2 on the
992+
day DST begins. How an Eastern class chooses to interpret 2:MM on
993+
that day is its business. The example Eastern class above chose to
994+
consider it as a time in EDT, simply because it "looks like it's
995+
after 2:00", and so synonymous with the EST 1:MM times on that day.
996+
Your Eastern class may wish, for example, to raise an exception instead
997+
when it sees a 2:MM time on the day Eastern begins.
998+
999+
When DST ends (the "end" line), there's a potentially worse problem:
1000+
there's an hour that can't be spelled at all in local wall time, the
1001+
hour beginning at the moment DST ends. In this example, that's times of
1002+
the form 6:MM UTC on the day daylight time ends. The local wall clock
1003+
leaps from 1:59 (daylight time) back to 1:00 (standard time) again.
1004+
1:MM is taken as daylight time (it's "before 2:00"), so maps to 5:MM UTC.
1005+
2:MM is taken as standard time (it's "after 2:00"), so maps to 7:MM UTC.
1006+
There is no local time that maps to 6:MM UTC on this day.
1007+
1008+
Just as the wall clock does, astimezone(Eastern) maps both UTC hours 5:MM
1009+
and 6:MM to Eastern hour 1:MM on this day. However, this result is
1010+
ambiguous (there's no way for Eastern to know which repetition of 1:MM
1011+
is intended). Applications that can't bear such ambiguity even one hour
1012+
per year should avoid using hybrid tzinfo classes; there are no
1013+
ambiguities when using UTC, or any other fixed-offset tzinfo subclass
1014+
(such as a class representing only EST (fixed offset -5 hours), or only
1015+
EDT (fixed offset -4 hours)).
1016+
9731017

9741018
\subsection{\class{timetz} Objects \label{datetime-timetz}}
9751019

Doc/lib/tzinfo-examples.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from datetime import tzinfo, timedelta
1+
from datetime import tzinfo, timedelta, datetime
22

33
ZERO = timedelta(0)
4+
HOUR = timedelta(hours=1)
45

56
# A UTC class.
67

@@ -76,3 +77,63 @@ def _isdst(self, dt):
7677
return tt.tm_isdst > 0
7778

7879
Local = LocalTimezone()
80+
81+
82+
# A complete implementation of current DST rules for major US time zones.
83+
84+
def first_sunday_on_or_after(dt):
85+
days_to_go = 6 - dt.weekday()
86+
if days_to_go:
87+
dt += timedelta(days_to_go)
88+
return dt
89+
90+
# In the US, DST starts at 2am (standard time) on the first Sunday in April.
91+
DSTSTART = datetime(1, 4, 1, 2)
92+
# and ends at 2am (DST time; 1am standard time) on the last Sunday of Oct.
93+
# which is the first Sunday on or after Oct 25.
94+
DSTEND = datetime(1, 10, 25, 2)
95+
96+
class USTimeZone(tzinfo):
97+
98+
def __init__(self, hours, reprname, stdname, dstname):
99+
self.stdoffset = timedelta(hours=hours)
100+
self.reprname = reprname
101+
self.stdname = stdname
102+
self.dstname = dstname
103+
104+
def __repr__(self):
105+
return self.reprname
106+
107+
def tzname(self, dt):
108+
if self.dst(dt):
109+
return self.dstname
110+
else:
111+
return self.stdname
112+
113+
def utcoffset(self, dt):
114+
return self.stdoffset + self.dst(dt)
115+
116+
def dst(self, dt):
117+
if dt is None or dt.tzinfo is None:
118+
# An exception may be sensible here, in one or both cases.
119+
# It depends on how you want to treat them. The astimezone()
120+
# implementation always passes a datetimetz with
121+
# dt.tzinfo == self.
122+
return ZERO
123+
assert dt.tzinfo is self
124+
125+
# Find first Sunday in April & the last in October.
126+
start = first_sunday_on_or_after(DSTSTART.replace(year=dt.year))
127+
end = first_sunday_on_or_after(DSTEND.replace(year=dt.year))
128+
129+
# Can't compare naive to aware objects, so strip the timezone from
130+
# dt first.
131+
if start <= dt.replace(tzinfo=None) < end:
132+
return HOUR
133+
else:
134+
return ZERO
135+
136+
Eastern = USTimeZone(-5, "Eastern", "EST", "EDT")
137+
Central = USTimeZone(-6, "Central", "CST", "CDT")
138+
Mountain = USTimeZone(-7, "Mountain", "MST", "MDT")
139+
Pacific = USTimeZone(-8, "Pacific", "PST", "PDT")

Lib/test/test_datetime.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2592,7 +2592,7 @@ def dst(self, dt):
25922592
utc_real = FixedOffset(0, "UTC", 0)
25932593
# For better test coverage, we want another flavor of UTC that's west of
25942594
# the Eastern and Pacific timezones.
2595-
utc_fake = FixedOffset(-12, "UTCfake", 0)
2595+
utc_fake = FixedOffset(-12*60, "UTCfake", 0)
25962596

25972597
class TestTimezoneConversions(unittest.TestCase):
25982598
# The DST switch times for 2002, in local time.
@@ -2643,25 +2643,17 @@ def checkinside(self, dt, tz, utc, dston, dstoff):
26432643
# 1:MM:SS is taken to be daylight time, and 2:MM:SS as
26442644
# standard time. The hour 1:MM:SS standard time ==
26452645
# 2:MM:SS daylight time can't be expressed in local time.
2646+
# Nevertheless, we want conversion back from UTC to mimic
2647+
# the local clock's "repeat an hour" behavior.
26462648
nexthour_utc = asutc + HOUR
2649+
nexthour_tz = nexthour_utc.astimezone(tz)
26472650
if dt.date() == dstoff.date() and dt.hour == 1:
26482651
# We're in the hour before DST ends. The hour after
2649-
# is ineffable.
2650-
# For concreteness, picture Eastern. during is of
2651-
# the form 1:MM:SS, it's daylight time, so that's
2652-
# 5:MM:SS UTC. Adding an hour gives 6:MM:SS UTC.
2653-
# Daylight time ended at 2+4 == 6:00:00 UTC, so
2654-
# 6:MM:SS is (correctly) taken to be standard time.
2655-
# But standard time is at offset -5, and that maps
2656-
# right back to the 1:MM:SS Eastern we started with.
2657-
# That's correct, too, *if* 1:MM:SS were taken as
2658-
# being standard time. But it's not -- on this day
2659-
# it's taken as daylight time.
2660-
self.assertRaises(ValueError,
2661-
nexthour_utc.astimezone, tz)
2652+
# is ineffable. We want the conversion back to repeat 1:MM.
2653+
expected_diff = ZERO
26622654
else:
2663-
nexthour_tz = nexthour_utc.astimezone(utc)
2664-
self.assertEqual(nexthour_tz - dt, HOUR)
2655+
expected_diff = HOUR
2656+
self.assertEqual(nexthour_tz - dt, expected_diff)
26652657

26662658
# Check a time that's outside DST.
26672659
def checkoutside(self, dt, tz, utc):
@@ -2739,6 +2731,31 @@ def test_tricky(self):
27392731
got = sixutc.astimezone(Eastern).astimezone(None)
27402732
self.assertEqual(expected, got)
27412733

2734+
# Now on the day DST ends, we want "repeat an hour" behavior.
2735+
# UTC 4:MM 5:MM 6:MM 7:MM checking these
2736+
# EST 23:MM 0:MM 1:MM 2:MM
2737+
# EDT 0:MM 1:MM 2:MM 3:MM
2738+
# wall 0:MM 1:MM 1:MM 2:MM against these
2739+
for utc in utc_real, utc_fake:
2740+
for tz in Eastern, Pacific:
2741+
first_std_hour = self.dstoff - timedelta(hours=3) # 23:MM
2742+
# Convert that to UTC.
2743+
first_std_hour -= tz.utcoffset(None)
2744+
# Adjust for possibly fake UTC.
2745+
asutc = first_std_hour + utc.utcoffset(None)
2746+
# First UTC hour to convert; this is 4:00 when utc=utc_real &
2747+
# tz=Eastern.
2748+
asutcbase = asutc.replace(tzinfo=utc)
2749+
for tzhour in (0, 1, 1, 2):
2750+
expectedbase = self.dstoff.replace(hour=tzhour)
2751+
for minute in 0, 30, 59:
2752+
expected = expectedbase.replace(minute=minute)
2753+
asutc = asutcbase.replace(minute=minute)
2754+
astz = asutc.astimezone(tz)
2755+
self.assertEqual(astz.replace(tzinfo=None), expected)
2756+
asutcbase += HOUR
2757+
2758+
27422759
def test_bogus_dst(self):
27432760
class ok(tzinfo):
27442761
def utcoffset(self, dt): return HOUR

Misc/NEWS

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ Extension modules
2626
- datetime changes:
2727

2828
today() and now() now round system timestamps to the closest
29-
microsecond <http://www.python.org/sf/661086>.
29+
microsecond <http://www.python.org/sf/661086>. This repairs an
30+
irritation most likely seen on Windows systems.
3031

3132
In dt.asdatetime(tz), if tz.utcoffset(dt) returns a duration,
3233
ValueError is raised if tz.dst(dt) returns None (2.3a1 treated it
33-
as 0 instead).
34+
as 0 instead, but a tzinfo subclass wishing to participate in
35+
time zone conversion has to take a stand on whether it supports
36+
DST; if you don't care about DST, then code dst() to return 0 minutes,
37+
meaning that DST is never in effect).
3438

3539
The tzinfo methods utcoffset() and dst() must return a timedelta object
3640
(or None) now. In 2.3a1 they could also return an int or long, but that
@@ -40,6 +44,12 @@ Extension modules
4044
The example tzinfo class for local time had a bug. It was replaced
4145
by a later example coded by Guido.
4246

47+
datetimetz.astimezone(tz) no longer raises an exception when the
48+
input datetime has no UTC equivalent in tz. For typical "hybrid" time
49+
zones (a single tzinfo subclass modeling both standard and daylight
50+
time), this case can arise one hour per year, at the hour daylight time
51+
ends. See new docs for details.
52+
4353
Library
4454
-------
4555

Modules/datetimemodule.c

Lines changed: 35 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4754,7 +4754,7 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args,
47544754

47554755
PyObject *result;
47564756
PyObject *temp;
4757-
int selfoff, resoff, resdst, total_added_to_result;
4757+
int selfoff, resoff, dst1, dst2;
47584758
int none;
47594759
int delta;
47604760

@@ -4792,19 +4792,24 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args,
47924792

47934793
/* See the long comment block at the end of this file for an
47944794
* explanation of this algorithm. That it always works requires a
4795-
* pretty intricate proof.
4795+
* pretty intricate proof. There are many equivalent ways to code
4796+
* up the proof as an algorithm. This way favors calling dst() over
4797+
* calling utcoffset(), because "the usual" utcoffset() calls dst()
4798+
* itself, and calling the latter instead saves a Python-level
4799+
* function call. This way of coding it also follows the proof
4800+
* closely, w/ x=self, y=result, z=result, and z'=temp.
47964801
*/
4797-
resdst = call_dst(tzinfo, result, &none);
4798-
if (resdst == -1 && PyErr_Occurred())
4802+
dst1 = call_dst(tzinfo, result, &none);
4803+
if (dst1 == -1 && PyErr_Occurred())
47994804
goto Fail;
48004805
if (none) {
48014806
PyErr_SetString(PyExc_ValueError, "astimezone(): utcoffset() "
48024807
"returned a duration but dst() returned None");
48034808
goto Fail;
48044809
}
4805-
total_added_to_result = resoff - resdst - selfoff;
4806-
if (total_added_to_result != 0) {
4807-
mm += total_added_to_result;
4810+
delta = resoff - dst1 - selfoff;
4811+
if (delta) {
4812+
mm += delta;
48084813
if ((mm < 0 || mm >= 60) &&
48094814
normalize_datetime(&y, &m, &d, &hh, &mm, &ss, &us) < 0)
48104815
goto Fail;
@@ -4814,58 +4819,47 @@ datetimetz_astimezone(PyDateTime_DateTimeTZ *self, PyObject *args,
48144819
Py_DECREF(result);
48154820
result = temp;
48164821

4817-
resoff = call_utcoffset(tzinfo, result, &none);
4818-
if (resoff == -1 && PyErr_Occurred())
4822+
dst1 = call_dst(tzinfo, result, &none);
4823+
if (dst1 == -1 && PyErr_Occurred())
48194824
goto Fail;
48204825
if (none)
48214826
goto Inconsistent;
48224827
}
4823-
4824-
/* The distance now from self to result is
4825-
* self - result == naive(self) - selfoff - (naive(result) - resoff) ==
4826-
* naive(self) - selfoff -
4827-
* ((naive(self) + total_added_to_result - resoff) ==
4828-
* - selfoff - total_added_to_result + resoff.
4829-
*/
4830-
delta = resoff - selfoff - total_added_to_result;
4831-
4832-
/* Now self and result are the same UTC time iff delta is 0.
4833-
* If it is 0, we're done, although that takes some proving.
4834-
*/
4835-
if (delta == 0)
4828+
if (dst1 == 0)
48364829
return result;
48374830

4838-
total_added_to_result += delta;
4839-
mm += delta;
4831+
mm += dst1;
48404832
if ((mm < 0 || mm >= 60) &&
48414833
normalize_datetime(&y, &m, &d, &hh, &mm, &ss, &us) < 0)
48424834
goto Fail;
4843-
48444835
temp = new_datetimetz(y, m, d, hh, mm, ss, us, tzinfo);
48454836
if (temp == NULL)
48464837
goto Fail;
4847-
Py_DECREF(result);
4848-
result = temp;
48494838

4850-
resoff = call_utcoffset(tzinfo, result, &none);
4851-
if (resoff == -1 && PyErr_Occurred())
4839+
dst2 = call_dst(tzinfo, temp, &none);
4840+
if (dst2 == -1 && PyErr_Occurred()) {
4841+
Py_DECREF(temp);
48524842
goto Fail;
4853-
if (none)
4843+
}
4844+
if (none) {
4845+
Py_DECREF(temp);
48544846
goto Inconsistent;
4847+
}
48554848

4856-
if (resoff - selfoff == total_added_to_result)
4857-
/* self and result are the same UTC time */
4858-
return result;
4859-
4860-
/* Else there's no way to spell self in zone tzinfo. */
4861-
PyErr_SetString(PyExc_ValueError, "astimezone(): the source "
4862-
"datetimetz can't be expressed in the target "
4863-
"timezone's local time");
4864-
goto Fail;
4849+
if (dst1 == dst2) {
4850+
/* The normal case: we want temp, not result. */
4851+
Py_DECREF(result);
4852+
result = temp;
4853+
}
4854+
else {
4855+
/* The "unspellable hour" at the end of DST. */
4856+
Py_DECREF(temp);
4857+
}
4858+
return result;
48654859

48664860
Inconsistent:
4867-
PyErr_SetString(PyExc_ValueError, "astimezone(): tz.utcoffset() "
4868-
"gave inconsistent results; cannot convert");
4861+
PyErr_SetString(PyExc_ValueError, "astimezone(): tz.dst() gave"
4862+
"inconsistent results; cannot convert");
48694863

48704864
/* fall thru to failure */
48714865
Fail:

0 commit comments

Comments
 (0)