blob: 616dda4e2d5a664dd2c3c4fe3308417f4e101d0f [file] [edit]
"""Properties that must hold for any valid IANA time zone data.
Unlike the tests in ``test_tzfile.py``, which pin the data they use so that
they can check specific historical transitions, these run against every key
in whatever data is installed: the data on TZPATH (with tzdata blocked) and
the tzdata package, via the ``tz_source`` fixture. In the ``tz`` CI jobs
that is the master branch of the tz database, so these are the tests that
catch the parser mishandling something new in the data.
"""
import calendar
import os
import zoneinfo
from datetime import datetime, timedelta
import pytest
from dateutil import tz
from dateutil.tz._tzfile import _TZStr
pytestmark = pytest.mark.tzdata_properties
UTC = tz.UTC
EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
# The standard library's C implementation of zoneinfo is an independent
# implementation of the same thing, so it is the reference.
PAST_YEARS = (1850, 1900, 1945, 1970, 1990, 2000, 2020)
FUTURE_YEARS = (2037, 2038, 2050, 2100, 2400, 9000)
FIXED_INSTANTS = [
datetime(year, month, 1, tzinfo=UTC)
for year in PAST_YEARS + FUTURE_YEARS
for month in (1, 7)
]
def _from_timestamp(ts):
try:
return EPOCH + timedelta(seconds=ts)
except OverflowError:
# e.g. the "big bang" transitions at -2**59 in fat files
return None
def _probe_instants(zone):
"""Instants (in UTC) around the interesting parts of a zone."""
instants = list(FIXED_INSTANTS)
# Explicit transitions: the first few, and the last ones, which are
# followed by the TZ string.
transitions = list(zone._trans_utc)
for ts in transitions[:3] + transitions[-10:]:
for delta in (-1, 0, 1):
instants.append(_from_timestamp(ts + delta))
# Transitions generated from the TZ string in the footer.
if isinstance(zone._tz_after, _TZStr):
std_offset = zone._tz_after.std.utcoff
std_offset = std_offset.days * 86400 + std_offset.seconds
for year in (2040, 2100):
for local_ts in zone._tz_after.transitions(year):
utc_ts = local_ts - std_offset
for delta in (-7201, -3601, -1, 0, 1, 3599, 7199):
instants.append(_from_timestamp(utc_ts + delta))
return [instant for instant in instants if instant is not None]
def _keys():
keys = sorted(tz.available_iana_timezones())
assert keys
return keys
def test_every_key_loads(tz_source):
"""Every available key loads, from the source it was found in."""
tzpath = [os.path.join(path, "") for path in tz.TZPATH]
for key in _keys():
zone = tz.gettz(key)
assert isinstance(zone, tz.tzfile), key
assert zone.key == key
if tz_source == "tzpath":
assert any(zone._filename.startswith(p) for p in tzpath), key
def test_utc_round_trip(tz_source):
"""Converting from UTC to local time and back gives the same instant."""
for key in _keys():
zone = tz.gettz(key)
for dt_utc in _probe_instants(zone):
dt = dt_utc.astimezone(zone)
assert dt.astimezone(UTC) == dt_utc, (key, dt_utc, dt)
def test_ambiguous_and_imaginary(tz_source):
"""datetime_ambiguous and datetime_exists agree with the fold offsets."""
for key in _keys():
zone = tz.gettz(key)
for dt_utc in _probe_instants(zone):
local = dt_utc.astimezone(zone).replace(tzinfo=None)
# Local times an hour either side are more likely to land in a
# gap or a fold near a transition.
for wall in (
local - timedelta(hours=1),
local,
local + timedelta(hours=1),
):
dt = wall.replace(tzinfo=zone)
offset_0 = tz.enfold(dt, fold=0).utcoffset()
offset_1 = tz.enfold(dt, fold=1).utcoffset()
case = (key, wall)
assert tz.datetime_ambiguous(dt) == (offset_0 > offset_1), case
assert tz.datetime_exists(dt) == (offset_0 >= offset_1), case
def test_matches_zoneinfo(tz_source):
"""utcoffset, tzname and fromutc agree with the stdlib zoneinfo."""
# dateutil and zoneinfo share TZPATH, and the tz_source
# fixture blocks (or allows) tzdata for both of them.
#
# This doesn't compare dst(), which zoneinfo infers differently, and gets
# wrong for some zones (e.g. gh-85102); test_dst_matches_tzdata_zi checks
# dst() against the source data instead.
for key in _keys():
zone = tz.gettz(key)
reference = zoneinfo.ZoneInfo.no_cache(key)
for dt_utc in _probe_instants(zone):
dt = dt_utc.astimezone(zone)
dt_ref = dt_utc.astimezone(reference)
if (dt.replace(tzinfo=None), dt.fold) != (
dt_ref.replace(tzinfo=None),
dt_ref.fold,
):
# zoneinfo gets some TZ strings wrong near the new year (e.g.
# "0/0,J365/23" rules); only accept a difference where its
# answer doesn't round trip and ours does.
assert dt_ref.astimezone(UTC) != dt_utc, (key, dt_utc)
assert dt.astimezone(UTC) == dt_utc, (key, dt_utc)
wall = dt_utc.replace(tzinfo=None)
for fold in (0, 1):
dt = tz.enfold(wall.replace(tzinfo=zone), fold=fold)
dt_ref = wall.replace(tzinfo=reference, fold=fold)
if (dt.utcoffset(), dt.tzname()) != (
dt_ref.utcoffset(),
dt_ref.tzname(),
):
# As above: zoneinfo's offset has to be one that its own
# fromutc contradicts, and ours has to be one ours agrees
# with, so a difference in a real gap still fails.
case = (key, wall, fold)
ref_rt = dt_ref.astimezone(UTC).astimezone(reference)
our_rt = dt.astimezone(UTC).astimezone(zone)
assert ref_rt.replace(tzinfo=None) != wall, case
assert our_rt.replace(tzinfo=None) == wall, case
####
# dst() against the source data
#
# TZif files record the total UTC offset of each local time type and whether
# it is DST, but not how much of the offset is DST, so dst() is inferred from
# the neighboring transitions (see tzfile._utcoff_to_dstoff). The tzdata.zi
# file installed alongside the TZif files (by the tz Makefile, and in the
# tzdata package) has the standard offset for every period of every zone, so
# away from the boundaries between periods, utcoffset() - dst() has to be the
# standard offset from tzdata.zi.
#
# The inference gets this wrong in one place, in the 2024a, 2025b and 2026e
# releases and the master branch, in the main, vanguard and rearguard forms and
# as fat and slim files: from the liberation of Paris until September 1945,
# France was on UTC+0 standard time with DST of 1 or 2 hours, but the TZif data
# looks the same as the CET (UTC+1) standard time that followed, with DST of 1
# hour. Only failures outside of these periods fail the test.
KNOWN_DST_INFERENCE_FAILURES = {
"Europe/Paris": [
(
datetime(1944, 8, 25, tzinfo=UTC),
datetime(1945, 9, 16, tzinfo=UTC),
),
],
}
def _is_known_dst_inference_failure(name, dt):
return any(
start <= dt < end
for start, end in KNOWN_DST_INFERENCE_FAILURES.get(name, ())
)
_ZI_MONTHS = (
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
)
_ZI_WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
# How far from a boundary between two periods of a zone (whose exact time
# depends on rules that aren't evaluated here) a sample has to be.
_ZI_BOUNDARY_MARGIN = 2 * 86400
def _zi_lookup(abbr, names):
# tzdata.zi uses the shortest unambiguous abbreviations, e.g. "Ja", "Au"
matches = [i for i, name in enumerate(names) if name.startswith(abbr)]
assert len(matches) == 1, abbr
return matches[0]
def _zi_seconds(value):
sign = -1 if value.startswith("-") else 1
parts = [int(part) for part in value.lstrip("-").split(":")] + [0, 0]
return sign * (parts[0] * 3600 + parts[1] * 60 + parts[2])
def _zi_until(fields):
"""The approximate UTC timestamp of an UNTIL column."""
year = int(fields[0])
month = _zi_lookup(fields[1], _ZI_MONTHS) + 1 if len(fields) > 1 else 1
day = 1
if len(fields) > 2:
day_spec = fields[2]
days_in_month = calendar.monthrange(year, month)[1]
if day_spec.startswith("last"):
weekday = _zi_lookup(day_spec[4:], _ZI_WEEKDAYS)
day = max(
d
for d in range(1, days_in_month + 1)
if calendar.weekday(year, month, d) == weekday
)
elif ">=" in day_spec or "<=" in day_spec:
op = ">=" if ">=" in day_spec else "<="
name, first = day_spec.split(op)
weekday = _zi_lookup(name, _ZI_WEEKDAYS)
step = 1 if op == ">=" else -1
day = int(first)
while calendar.weekday(year, month, day) != weekday:
day += step
else:
day = int(day_spec)
seconds = _zi_seconds(fields[3].rstrip("wsugz")) if len(fields) > 3 else 0
days = (datetime(year, month, 1) - datetime(1970, 1, 1)).days + day - 1
return days * 86400 + seconds
def _parse_tzdata_zi(path):
"""Returns ({zone: [(stdoff, until), ...]}, {link: target})."""
zones = {}
links = {}
periods = None
with open(path) as f:
for line in f:
fields = line.split("#", 1)[0].split()
if not fields:
continue
if fields[0] == "Z":
periods = zones.setdefault(fields[1], [])
fields = fields[2:]
elif fields[0] in ("R", "L"):
if fields[0] == "L":
links[fields[2]] = fields[1]
periods = None
continue
# A zone line (without "Z <name>") or a continuation line:
# STDOFF RULES FORMAT [UNTIL]
until = _zi_until(fields[3:]) if len(fields) > 3 else None
periods.append((_zi_seconds(fields[0]), until))
return zones, links
def _find_tzdata_zi(tz_source):
if tz_source == "tzdata":
import tzdata
candidates = [
os.path.join(os.path.dirname(tzdata.__file__), "zoneinfo")
]
else:
candidates = tz.TZPATH
for directory in candidates:
path = os.path.join(directory, "tzdata.zi")
if os.path.exists(path):
return path
return None
def test_dst_matches_tzdata_zi(tz_source):
"""utcoffset() - dst() is the standard offset given in tzdata.zi."""
path = _find_tzdata_zi(tz_source)
if path is None:
pytest.skip("No tzdata.zi alongside the TZif files")
zones, links = _parse_tzdata_zi(path)
failures = {}
for key in _keys():
name = key
while name in links:
name = links[name]
periods = zones.get(name)
if periods is None:
continue
boundaries = [until for _, until in periods if until is not None]
zone = tz.gettz(key)
# Samples in the middle of the stretches between transitions, which
# are where the answer is least ambiguous.
transitions = list(zone._trans_utc)
samples = [(a + b) // 2 for a, b in zip(transitions, transitions[1:])]
samples += [
int((instant - EPOCH).total_seconds()) for instant in FIXED_INSTANTS
]
for ts in samples:
if any(abs(ts - b) < _ZI_BOUNDARY_MARGIN for b in boundaries):
continue
dt_utc = _from_timestamp(ts)
if dt_utc is None:
continue
stdoff = next(
stdoff
for stdoff, until in periods
if until is None or ts < until
)
dt = dt_utc.astimezone(zone)
std = dt.utcoffset() - dt.dst()
if std.days * 86400 + std.seconds != stdoff:
failures.setdefault(name, []).append(dt)
unexpected = {}
for name, dts in failures.items():
dts = [
dt for dt in dts if not _is_known_dst_inference_failure(name, dt)
]
if dts:
unexpected[name] = dts[:3]
assert not unexpected