Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions Lib/test/test_zoneinfo/test_zoneinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,145 @@ def test_tzstr_from_utc(self):

self.assertEqual(dt_act, dt_utc)

def test_permanent_dst_year_boundary(self):
cases = (
("STD-2DST-1,J1/0,J365/23", 1, -1),
("STD2DST1,J1/0,J365/25", -1, 1),
)
for tzstr, offset, dst in cases:
zi = self.zone_from_tzstr(tzstr)
for year in (1, 2, 2019, 2020, 2100, 2400, 9998, 9999):
for month, day in ((1, 1), (12, 31)):
for hour in (2, 12, 21, 22, 23):
utc = datetime(year, month, day, hour,
tzinfo=timezone.utc)
# Keep the expected local time within datetime's range.
if year == 9999 and month == 12 and hour == 23:
continue
with self.subTest(tzstr=tzstr, utc=utc):
local = utc.astimezone(zi)
expected = utc.replace(tzinfo=None)
expected += timedelta(hours=offset)
self.assertEqual(local.replace(tzinfo=None), expected)
self.assertEqual(local.fold, 0)
self.assertEqual(local.tzname(), "DST")
self.assertEqual(local.dst(), timedelta(hours=dst))
self.assertEqual(local.astimezone(timezone.utc), utc)
for fold in (0, 1):
self.assertEqual(
local.replace(fold=fold).utcoffset(),
timedelta(hours=offset),
)

def test_transitions_in_adjacent_year(self):
# UTC transition instants are stated explicitly, independently of the
# rule evaluation. Cover starts and ends, either sign of DST, and
# rules displaced by both UTC offsets and extended transition hours.
cases = (
# Positive DST starts on January 1 locally, but December 31 UTC.
("STD-2DST-3,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 3),
# Positive DST starts in January from the previous year's J365.
("STD2DST1,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -1),
# Negative DST starts on January 1 locally, but December 31 UTC.
("STD-2DST-1,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 1),
# Negative DST starts in January from the previous year's J365.
("STD2DST3,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -3),
# Positive DST ends on January 1 locally, but December 31 UTC.
("STD-2DST-3,J200/0,J1/0", datetime(2100, 12, 31, 21), 3, 2),
# Positive DST ends in January from the previous year's J365.
("STD2DST1,J200/0,J365/26", datetime(2101, 1, 1, 3), -1, -2),
# Negative DST ends on January 1 locally, but December 31 UTC.
("STD-2DST-1,J200/0,J1/0", datetime(2100, 12, 31, 23), 1, 2),
# Negative DST ends in January from the previous year's J365.
("STD2DST3,J200/0,J365/26", datetime(2101, 1, 1, 5), -3, -2),
# The most negative transition hour moves J1 into December.
("STD0DST-1,J1/-167,J200/0", datetime(2099, 12, 25, 1), 0, 1),
# The largest transition hour moves J365 into January.
("STD0DST-1,J200/0,J365/167", datetime(2101, 1, 6, 22), 1, 0),
# A month/week rule from year 0 supplies a transition in year 1.
("STD0DST-1,M12.5.0/167,M6.1.0/0", datetime(1, 1, 6, 23), 0, 1),
# A year-10000 rule supplies a transition within datetime's range.
("STD0DST-1,J1/-167,J200/0", datetime(9999, 12, 25, 1), 0, 1),
# A season starting in rule year 2023 ends in January 2025.
("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 21), 1, 0),
# The next season starts two hours after the preceding one ends.
("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 23), 0, 1),
# The same two-rule-year lookback must work through year -1.
("STD0DST-1,J365/167,J365/166", datetime(1, 1, 6, 21), 1, 0),
)
for tzstr, transition, old_hours, new_hours in cases:
zi = self.zone_from_tzstr(tzstr)
old_offset = timedelta(hours=old_hours)
new_offset = timedelta(hours=new_hours)
for seconds in (-3600, -1, 0, 1, 3599, 3600, 3601):
utc = (transition + timedelta(seconds=seconds)).replace(
tzinfo=timezone.utc
)
offset = old_offset if seconds < 0 else new_offset
expected = utc.replace(tzinfo=None) + offset
fold = int(new_hours < old_hours and 0 <= seconds < 3600)
with self.subTest(tzstr=tzstr, utc=utc):
local = utc.astimezone(zi)
self.assertEqual(local.replace(tzinfo=None), expected)
self.assertEqual(local.utcoffset(), offset)
self.assertEqual(local.fold, fold)
self.assertEqual(local.astimezone(timezone.utc), utc)

# Local lookups must disambiguate the gap or fold on both sides
# of New Year even when it belongs to a different rule year.
low = transition + min(old_offset, new_offset)
for seconds in (0, 1, 3599):
for fold, offset in ((0, old_offset), (1, new_offset)):
local = (low + timedelta(seconds=seconds)).replace(
tzinfo=zi, fold=fold
)
with self.subTest(tzstr=tzstr, local=local, fold=fold):
self.assertEqual(local.utcoffset(), offset)

def test_rule_order_changes_between_years(self):
# In 2021 the first Sunday in March follows the first Monday,
# whereas in 2022 it precedes it. The 2021 DST period continues
# until March 2022, overlapping the short 2022 period.
zi = self.zone_from_tzstr("STD0DST-1,M3.1.0/0,M3.1.1/0")
for utc in (
datetime(2022, 1, 15, tzinfo=timezone.utc),
datetime(2022, 2, 1, tzinfo=timezone.utc),
datetime(2022, 3, 6, tzinfo=timezone.utc),
):
with self.subTest(utc=utc):
local = utc.astimezone(zi)
self.assertEqual(local.utcoffset(), ONE_H)
self.assertEqual(local.fold, 0)
self.assertEqual(local.astimezone(timezone.utc), utc)

def test_short_dst_period_fold(self):
# A short DST period can end before its initial gap has elapsed.
# Only local times that actually occur twice should have fold=1.
# Exercise short periods near and away from New Year.
for day, month, month_day in ((1, 1, 1), (100, 4, 10)):
for dst_hours, end in ((1, "1:30"), (-1, "-0:30")):
tzstr = f"STD0DST{-dst_hours},J{day}/0,J{day}/{end}"
zi = self.zone_from_tzstr(tzstr)
transition = datetime(2025, month, month_day,
tzinfo=timezone.utc)
for minute in (-1, 0, 29, 30, 40, 59, 60, 89, 90):
utc = transition + timedelta(minutes=minute)
in_dst = 0 <= minute < 30
offset = timedelta(hours=dst_hours if in_dst else 0)
if dst_hours > 0:
fold = int(60 <= minute < 90)
else:
fold = int(in_dst)
with self.subTest(tzstr=tzstr, utc=utc):
local = utc.astimezone(zi)
self.assertEqual(local.fold, fold)
self.assertEqual(local.utcoffset(), offset)
self.assertEqual(
local.replace(tzinfo=None),
utc.replace(tzinfo=None) + offset,
)
self.assertEqual(local.astimezone(timezone.utc), utc)

def test_extreme_tzstr(self):
tzstrs = [
# Extreme offset hour
Expand Down Expand Up @@ -1534,6 +1673,29 @@ def _add():
(datetime(2012, 11, 4, 2, 15, 45), AAA, NORMAL),
)

@call
def _add():
# Numeric day rules count from January 1, including leap days.
STD = ZoneOffset("STD", ZERO)
DST = ZoneOffset("DST", ONE_H, ONE_H)

for day in (0, 10, 59, 60, 364, 365):
tzstr = f"STD0DST,{day}/2,{day}/14"
day_cases = []
for year in (2019, 2020):
date = datetime(year, 1, 1) + timedelta(days=day)
day_cases.extend((
(date.replace(hour=1, minute=59), STD, NORMAL),
(date.replace(hour=2, fold=0), STD, GAP),
(date.replace(hour=2, fold=1), DST, GAP),
(date.replace(hour=3), DST, NORMAL),
(date.replace(hour=12, minute=59), DST, NORMAL),
(date.replace(hour=13, fold=0), DST, FOLD),
(date.replace(hour=13, fold=1), STD, FOLD),
(date.replace(hour=14), STD, NORMAL),
))
cases[tzstr] = day_cases

cls.test_cases = cases


Expand Down
118 changes: 91 additions & 27 deletions Lib/zoneinfo/_zoneinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
EPOCH = datetime(1970, 1, 1)
EPOCHORDINAL = datetime(1970, 1, 1).toordinal()

_SECONDS_PER_DAY = 24 * 60 * 60

# It is relatively expensive to construct new timedelta objects, and in most
# cases we're looking at the same deltas, like integer numbers of hours, etc.
# To improve speed and memory use, we'll keep a dictionary with references
Expand Down Expand Up @@ -462,9 +464,44 @@ def transitions(self, year):
end = self.end.year_to_epoch(year)
return start, end

def _dst_period(self, year):
start, end = self.transitions(year)
start -= self.std.utcoff.total_seconds()
end -= self.dst.utcoff.total_seconds()
if end <= start:
end = self.end.year_to_epoch(year + 1)
end -= self.dst.utcoff.total_seconds()
return start, end

def _get_trans_info(self, ts, year, fold):
"""Get the information about the current transition - tti"""
start, end = self.transitions(year)

year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY
# RFC 9636, section 3.3.2 specifies transition hours from -167 to 167
# for TZif v3+; _parse_transition_time enforces this limit.
# https://www.rfc-editor.org/rfc/rfc9636.html#section-3.3.2
# _parse_tz_delta caps offset hours at 24 (the POSIX TZ limit),
# with one extra hour possible for default DST.
# Including minute/second fields and the offset difference across a
# fold, the displacement is less than 11 days. Use a common year's
# length here, leaving an extra day of margin in leap years.
if (year_start + 11 * _SECONDS_PER_DAY <= ts
< year_start + (365 - 11) * _SECONDS_PER_DAY):
start, end = self.transitions(year)
# An M rule selects a weekday within a week: its date can vary
# by six days, plus one day-of-year for leap years. Allow seven
# days for each rule. Nearby rules can reverse order, allowing
# a previous southern season to overlap this northern one.
if abs(end - start - self.dst_diff) > 2 * 7 * _SECONDS_PER_DAY:
if fold == (self.dst_diff >= 0):
end -= self.dst_diff
else:
start += self.dst_diff
if start < end:
isdst = start <= ts < end
else:
isdst = not (end <= ts < start)
return self.dst if isdst else self.std

# With fold = 0, the period (denominated in local time) with the
# smaller offset starts at the end of the gap and ends at the end of
Expand All @@ -475,38 +512,62 @@ def _get_trans_info(self, ts, year, fold):
# the fold and whether DST is positive or negative (rare), and it
# turns out that this boils down to fold XOR is_positive.
if fold == (self.dst_diff >= 0):
end -= self.dst_diff
else:
start += self.dst_diff

if start < end:
isdst = start <= ts < end
ts -= self.std.utcoff.total_seconds()
else:
isdst = not (end <= ts < start)

return self.dst if isdst else self.std
ts -= self.dst.utcoff.total_seconds()

# Transition times and UTC offsets can move a rule into an adjacent
# year. Taking the union also joins the touching periods used to
# represent permanent DST, without introducing a gap or fold.
# A southern season with a start shifted into the following January
# can still be active two rule years after its start year.
for rule_year in (year, year - 1, year + 1, year - 2):
start, end = self._dst_period(rule_year)
if start <= ts < end:
return self.dst
return self.std

def _get_trans_info_fromutc(self, ts, year):
start, end = self.transitions(year)
start -= self.std.utcoff.total_seconds()
end -= self.dst.utcoff.total_seconds()

if start < end:
isdst = start <= ts < end
else:
isdst = not (end <= ts < start)
year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY
# As in _get_trans_info, the parser limits and fold lookback fit
# within 11 days. Using 365 leaves an extra margin in leap years.
if (year_start + 11 * _SECONDS_PER_DAY <= ts
< year_start + (365 - 11) * _SECONDS_PER_DAY):
start, end = self.transitions(year)
start -= self.std.utcoff.total_seconds()
end -= self.dst.utcoff.total_seconds()
# Each M rule can move six calendar days plus a leap day;
# allow seven days for each rule to keep their order stable.
if abs(end - start) > 2 * 7 * _SECONDS_PER_DAY:
before = ts - abs(self.dst_diff)
if start < end:
isdst = start <= ts < end
wasdst = start <= before < end
else:
isdst = not (end <= ts < start)
wasdst = not (end <= before < start)
if self.dst_diff > 0:
fold = wasdst and not isdst
else:
fold = isdst and not wasdst
return self.dst if isdst else self.std, fold

isdst = False
wasdst = False
before = ts - abs(self.dst_diff)
for rule_year in (year, year - 1, year + 1, year - 2):
start, end = self._dst_period(rule_year)
isdst |= start <= ts < end
wasdst |= start <= before < end
if isdst and wasdst:
break

# For positive DST, the ambiguous period is one dst_diff after the end
# of DST; for negative DST, the ambiguous period is one dst_diff before
# the start of DST.
# A fold requires an actual decrease in offset. A nominal transition
# at the join between permanent-DST periods does not create one.
if self.dst_diff > 0:
ambig_start = end
ambig_end = end + self.dst_diff
fold = wasdst and not isdst
else:
ambig_start = start
ambig_end = start - self.dst_diff

fold = ambig_start <= ts < ambig_end
fold = isdst and not wasdst

return (self.dst if isdst else self.std, fold)

Expand Down Expand Up @@ -537,6 +598,9 @@ def year_to_epoch(self, year):
d = self.d
if self.julian and d >= 59 and calendar.isleap(year):
d += 1
elif not self.julian:
# Numeric day rules are zero-based, unlike Julian day rules.
d += 1

epoch = (days_before_year + d) * 86400
epoch += self.hour * 3600 + self.minute * 60 + self.second
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix :class:`zoneinfo.ZoneInfo` evaluating numeric day-of-year rules in TZ
strings one day early.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fix :class:`zoneinfo.ZoneInfo` handling of TZ string transitions that fall in
an adjacent year, including permanent daylight saving time and folds near
New Year.
Loading
Loading