blob: 5d15a3e6d28626e4c34bd8e49771e3af86b5bcb6 [file] [edit]
"""Tests for `tz.tzfile`."""
import base64
import functools
import gzip
import io
import json
import os
import pickle
import shutil
import struct
import sys
import tempfile
import threading
import time as time_module
import warnings
from datetime import date, datetime, time, timedelta
import attr
import pytest
from dateutil import tz
from ._common import set_tzpath
IS_WIN = sys.platform.startswith("win")
MIN_DT_V1 = datetime(1970, 1, 1) - timedelta(seconds=2**31)
MAX_DT_V1 = datetime(1970, 1, 1) + timedelta(seconds=2**31)
####
# Test utilities
def _copy_resource_to(resource, path):
"""Copies a test resource to a path on disk."""
from dateutil._tzdata_impl import _open_binary
parent_dir = os.path.dirname(path)
if not os.path.exists(parent_dir):
os.makedirs(parent_dir)
with _open_binary("tests.resources", resource) as f:
with open(path, "wb") as out_f:
out_f.write(f.read())
@pytest.fixture
def transition(request, tzpath_zoneinfo_cache):
key, dt, fold, offset = request.param
if dt.tzinfo is not None:
dt_n = dt.astimezone(tz.UTC).replace(tzinfo=None)
else:
dt_n = dt
if tzpath_zoneinfo_cache == "v1" and not (MIN_DT_V1 <= dt_n <= MAX_DT_V1):
pytest.skip(
"Out of range for V1 files"
) # TODO: Avoid generating these tests at all
yield ExpectedTransition(key=key, dt=dt, fold=fold, offset=offset)
@pytest.fixture(scope="module")
def zoneinfo_cache(request):
"""
A fixture that extracts TZif files from a JSON file mapping timezone names to
base64-encoded gzipped TZif data.
The files are extracted into a temporary directory once per module and
deleted afterwards.
Returns:
tuple: The path to the directory containing the extracted timezone
files, and the name of the data set.
"""
input_file = os.path.join(
os.path.dirname(__file__),
"resources",
"data",
"zoneinfo_%s.json" % request.param,
)
cache_dir = tempfile.mkdtemp(prefix="zoneinfo_%s_" % request.param)
try:
extract_tzif_files(input_file, cache_dir)
yield os.path.abspath(cache_dir), request.param
finally:
shutil.rmtree(cache_dir)
def extract_tzif_files(input_file, cache_dir):
"""
Extract TZif files from a JSON file and save them to the cache directory.
Args:
input_file: Path to the JSON file containing the TZif data.
cache_dir: Path to the directory where the extracted files will be stored.
"""
try:
with open(input_file, "r") as f:
timezone_data = json.load(f)
for timezone_name, encoded_data in timezone_data.items():
# Create the directory structure for the timezone
timezone_path = os.path.join(cache_dir, timezone_name)
os.makedirs(os.path.dirname(timezone_path), exist_ok=True)
# Decode and decompress the TZif data
compressed_data = base64.b64decode(encoded_data)
tzif_data = gzip.decompress(compressed_data)
# Write the TZif file
with open(timezone_path, "wb") as f:
f.write(tzif_data)
except (json.JSONDecodeError, OSError) as e:
raise RuntimeError(
"Failed to extract TZif files from {input_file}: {e}".format(
input_file=input_file, e=e
)
)
def construct_zone(
transitions, after=None, version=3, leap_seconds=(), indicators=False
):
"""Builds a TZif file.
``leap_seconds`` is a sequence of ``(timestamp, correction)`` pairs, and
``indicators`` adds standard/wall and UT/local indicators for every local
time type. Neither affects what the file means to ``tzfile``, which skips
over them, but they change the layout of the file.
"""
offset_lists = [[], []]
trans_times_lists = [[], []]
trans_idx_lists = [[], []]
v1_range = (-(2**31), 2**31)
v2_range = (-(2**63), 2**63)
ranges = [v1_range, v2_range]
def zt_as_tuple(zt):
# zt may be a tuple (timestamp, offset_before, offset_after) or
# a ZoneTransition object -- this is to allow the timestamp to be
# values that are outside the valid range for datetimes but still
# valid 64-bit timestamps.
if isinstance(zt, tuple):
return zt
if zt.transition:
trans_time = int(datetime.timestamp(zt.transition_utc))
else:
trans_time = None
return (trans_time, zt.offset_before, zt.offset_after)
transitions = sorted(map(zt_as_tuple, transitions), key=lambda x: x[0])
for zt in transitions:
trans_time, offset_before, offset_after = zt
for v, (dt_min, dt_max) in enumerate(ranges):
offsets = offset_lists[v]
trans_times = trans_times_lists[v]
trans_idx = trans_idx_lists[v]
if trans_time is not None and not (dt_min <= trans_time <= dt_max):
continue
if offset_before not in offsets:
offsets.append(offset_before)
if offset_after not in offsets:
offsets.append(offset_after)
if trans_time is not None:
trans_times.append(trans_time)
trans_idx.append(offsets.index(offset_after))
zonefile = io.BytesIO()
time_types = ("l", "q")
for v in range(min((version, 2))):
offsets = offset_lists[v]
trans_times = trans_times_lists[v]
trans_idx = trans_idx_lists[v]
time_type = time_types[v]
# Translate the offsets into something closer to the C values
abbrstr = bytearray()
ttinfos = []
for offset in offsets:
utcoff = int(offset.utcoffset.total_seconds())
isdst = bool(offset.dst)
abbrind = len(abbrstr)
ttinfos.append((utcoff, isdst, abbrind))
abbrstr += offset.tzname.encode("ascii") + b"\x00"
abbrstr = bytes(abbrstr)
typecnt = len(offsets)
timecnt = len(trans_times)
charcnt = len(abbrstr)
dt_min, dt_max = ranges[v]
leaps = [
(trans_time, correction)
for trans_time, correction in leap_seconds
if dt_min <= trans_time <= dt_max
]
leapcnt = len(leaps)
isstd = isutc = [0] * typecnt if indicators else []
isstdcnt = len(isstd)
isutcnt = len(isutc)
# Write the header
zonefile.write(b"TZif")
# Version 1 files have a NUL byte in place of the version number
if version == 1:
zonefile.write(b"\x00")
else:
zonefile.write(("%d" % version).encode("ascii"))
zonefile.write(b"\x00" * 15) # Reserved
zonefile.write(
struct.pack(
">6l", isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt
)
)
# Now the transition data
zonefile.write(
struct.pack(
">{timecnt}{time_type}".format(
timecnt=timecnt, time_type=time_type
),
*trans_times
)
)
zonefile.write(
struct.pack(">{timecnt}B".format(timecnt=timecnt), *trans_idx)
)
for ttinfo in ttinfos:
zonefile.write(struct.pack(">lbb", *ttinfo))
zonefile.write(bytes(abbrstr))
# Now the leap second records and the indicators, in that order
for leap in leaps:
zonefile.write(struct.pack(">%sl" % time_type, *leap))
zonefile.write(
struct.pack("{isstdcnt}b".format(isstdcnt=isstdcnt), *isstd)
)
zonefile.write(
struct.pack("{isutcnt}b".format(isutcnt=isutcnt), *isutc)
)
# Finally we write the TZ string if we're writing a Version 2+ file
if v > 0:
zonefile.write(b"\x0a")
if after is None:
after = ""
zonefile.write(after.encode("ascii"))
zonefile.write(b"\x0a")
zonefile.seek(0)
return zonefile
@functools.cache
def _tzstr_header():
out = bytearray()
# The TZif format always starts with a Version 1 file followed by
# the Version 2+ file. In this case, we have no transitions, just
# the tzstr in the footer, so up to the footer, the files are
# identical and we can just write the same file twice in a row.
for _ in range(2):
out += b"TZif" # Magic value
out += b"3" # Version
out += b" " * 15 # Reserved
# We will not write any of the manual transition parts
out += struct.pack(">6l", 0, 0, 0, 0, 0, 0)
return bytes(out)
def zone_from_tzstr(tzstr, encoding="ascii"):
"""Creates a zoneinfo file following a POSIX rule."""
zonefile = io.BytesIO(_tzstr_header())
zonefile.seek(0, 2)
# Write the footer
zonefile.write(b"\x0a")
zonefile.write(tzstr.encode(encoding))
zonefile.write(b"\x0a")
zonefile.seek(0)
return tz.tzfile(zonefile, key=tzstr)
@pytest.fixture
def tzpath_zoneinfo_cache(zoneinfo_cache):
# Parameterize this indirectly by parameterizing zoneinfo_cache
with set_tzpath((zoneinfo_cache[0],), block_tzdata=True):
yield zoneinfo_cache[1]
def as_list(f):
@functools.wraps(f)
def inner_func(*args, **kwargs):
return list(f(*args, **kwargs))
return inner_func
####
# Classes representing test data
ZERO = timedelta(0)
ONE_H = timedelta(hours=1)
SECOND = timedelta(seconds=1)
@attr.s
class ZoneOffset:
tzname = attr.ib()
utcoffset = attr.ib()
dst = attr.ib(default=ZERO)
@attr.s
class ZoneTransition:
transition = attr.ib()
offset_before = attr.ib()
offset_after = attr.ib()
marks = attr.ib(default=None)
@property
def transition_utc(self):
return (self.transition - self.offset_before.utcoffset).replace(
tzinfo=tz.UTC
)
@property
def wall_transition(self):
"""The local transition time using the (possibly rounded) offsets
Identical to ``self.transition`` when sub-minute offsets are supported.
"""
return (
self.transition_utc.replace(tzinfo=None)
+ self.offset_before.utcoffset
)
@property
def fold(self):
"""Whether this introduces a fold"""
return self.offset_before.utcoffset > self.offset_after.utcoffset
@property
def gap(self):
"""Whether this introduces a gap"""
return self.offset_before.utcoffset < self.offset_after.utcoffset
@property
def delta(self):
return self.offset_after.utcoffset - self.offset_before.utcoffset
@property
def anomaly_start(self):
if self.fold:
return self.wall_transition + self.delta
else:
return self.wall_transition
@property
def anomaly_end(self):
if not self.fold:
return self.wall_transition + self.delta
else:
return self.wall_transition
@attr.frozen
class ExpectedTransition:
key = attr.ib()
dt = attr.ib()
fold = attr.ib()
offset = attr.ib()
####
# Test data
@functools.cache
def get_zonedump_data():
def _zone_dump_data():
def _Africa_Abidjan():
LMT = ZoneOffset("LMT", timedelta(seconds=-968))
GMT = ZoneOffset("GMT", ZERO)
return [
ZoneTransition(datetime(1912, 1, 1), LMT, GMT),
]
def _Africa_Casablanca():
P00_s = ZoneOffset("+00", ZERO, ZERO)
P01_d = ZoneOffset("+01", ONE_H, ONE_H)
P00_d = ZoneOffset("+00", ZERO, -ONE_H)
P01_s = ZoneOffset("+01", ONE_H, ZERO)
return [
# Morocco sometimes pauses DST during Ramadan
ZoneTransition(datetime(2018, 3, 25, 2), P00_s, P01_d),
ZoneTransition(datetime(2018, 5, 13, 3), P01_d, P00_s),
ZoneTransition(datetime(2018, 6, 17, 2), P00_s, P01_d),
# On October 28th Morocco set standard time to +01,
# with negative DST only during Ramadan
ZoneTransition(datetime(2018, 10, 28, 3), P01_d, P01_s),
ZoneTransition(datetime(2019, 5, 5, 3), P01_s, P00_d),
ZoneTransition(datetime(2019, 6, 9, 2), P00_d, P01_s),
]
def _America_Los_Angeles():
LMT = ZoneOffset("LMT", timedelta(seconds=-28378), ZERO)
PST = ZoneOffset("PST", timedelta(hours=-8), ZERO)
PDT = ZoneOffset("PDT", timedelta(hours=-7), ONE_H)
PWT = ZoneOffset("PWT", timedelta(hours=-7), ONE_H)
PPT = ZoneOffset("PPT", timedelta(hours=-7), ONE_H)
return [
ZoneTransition(datetime(1883, 11, 18, 12, 7, 2), LMT, PST),
ZoneTransition(datetime(1918, 3, 31, 2), PST, PDT),
ZoneTransition(datetime(1918, 3, 31, 2), PST, PDT),
ZoneTransition(datetime(1918, 10, 27, 2), PDT, PST),
# Transition to Pacific War Time
ZoneTransition(datetime(1942, 2, 9, 2), PST, PWT),
# Transition from Pacific War Time to Pacific Peace Time
ZoneTransition(datetime(1945, 8, 14, 16), PWT, PPT),
ZoneTransition(datetime(1945, 9, 30, 2), PPT, PST),
ZoneTransition(datetime(2015, 3, 8, 2), PST, PDT),
ZoneTransition(datetime(2015, 11, 1, 2), PDT, PST),
# After 2038: Rules continue indefinitely
ZoneTransition(datetime(2450, 3, 13, 2), PST, PDT),
ZoneTransition(datetime(2450, 11, 6, 2), PDT, PST),
]
def _America_Santiago():
LMT = ZoneOffset("LMT", timedelta(seconds=-16965), ZERO)
SMT = ZoneOffset("SMT", timedelta(seconds=-16965), ZERO)
N05 = ZoneOffset("-05", timedelta(seconds=-18000), ZERO)
N04 = ZoneOffset("-04", timedelta(seconds=-14400), ZERO)
N03 = ZoneOffset("-03", timedelta(seconds=-10800), ONE_H)
return [
ZoneTransition(datetime(1890, 1, 1), LMT, SMT),
ZoneTransition(datetime(1910, 1, 10), SMT, N05),
ZoneTransition(datetime(1916, 7, 1), N05, SMT),
ZoneTransition(datetime(2008, 3, 30), N03, N04),
ZoneTransition(datetime(2008, 10, 12), N04, N03),
ZoneTransition(datetime(2040, 4, 8), N03, N04),
ZoneTransition(datetime(2040, 9, 2), N04, N03),
]
def _Asia_Tokyo():
JST = ZoneOffset("JST", timedelta(seconds=32400), ZERO)
JDT = ZoneOffset("JDT", timedelta(seconds=36000), ONE_H)
# Japan had DST from 1948 to 1951, and it was unusual in that
# the transition from DST to STD occurred at 25:00, and is
# denominated as such in the time zone database
return [
ZoneTransition(datetime(1948, 5, 2), JST, JDT),
ZoneTransition(datetime(1948, 9, 12, 1), JDT, JST),
ZoneTransition(datetime(1951, 9, 9, 1), JDT, JST),
]
def _Australia_Sydney():
LMT = ZoneOffset("LMT", timedelta(seconds=36292), ZERO)
AEST = ZoneOffset("AEST", timedelta(seconds=36000), ZERO)
AEDT = ZoneOffset("AEDT", timedelta(seconds=39600), ONE_H)
return [
ZoneTransition(datetime(1895, 2, 1), LMT, AEST),
ZoneTransition(datetime(1917, 1, 1, 2), AEST, AEDT),
ZoneTransition(datetime(1917, 3, 25, 3), AEDT, AEST),
ZoneTransition(datetime(2012, 4, 1, 3), AEDT, AEST),
ZoneTransition(datetime(2012, 10, 7, 2), AEST, AEDT),
ZoneTransition(datetime(2040, 4, 1, 3), AEDT, AEST),
ZoneTransition(datetime(2040, 10, 7, 2), AEST, AEDT),
]
def _Europe_Dublin():
LMT = ZoneOffset("LMT", timedelta(seconds=-1521), ZERO)
DMT = ZoneOffset("DMT", timedelta(seconds=-1521), ZERO)
IST_0 = ZoneOffset("IST", timedelta(seconds=2079), ONE_H)
GMT_0 = ZoneOffset("GMT", ZERO, ZERO)
BST = ZoneOffset("BST", ONE_H, ONE_H)
GMT_1 = ZoneOffset("GMT", ZERO, -ONE_H)
IST_1 = ZoneOffset("IST", ONE_H, ZERO)
return [
ZoneTransition(datetime(1880, 8, 2, 0), LMT, DMT),
ZoneTransition(datetime(1916, 5, 21, 2), DMT, IST_0),
ZoneTransition(datetime(1916, 10, 1, 3), IST_0, GMT_0),
ZoneTransition(datetime(1917, 4, 8, 2), GMT_0, BST),
ZoneTransition(datetime(2016, 3, 27, 1), GMT_1, IST_1),
ZoneTransition(datetime(2016, 10, 30, 2), IST_1, GMT_1),
ZoneTransition(datetime(2487, 3, 30, 1), GMT_1, IST_1),
ZoneTransition(datetime(2487, 10, 26, 2), IST_1, GMT_1),
]
def _Europe_Lisbon():
WET = ZoneOffset("WET", ZERO, ZERO)
WEST = ZoneOffset("WEST", ONE_H, ONE_H)
CET = ZoneOffset("CET", ONE_H, ZERO)
CEST = ZoneOffset("CEST", timedelta(seconds=7200), ONE_H)
return [
ZoneTransition(datetime(1992, 3, 29, 1), WET, WEST),
ZoneTransition(datetime(1992, 9, 27, 2), WEST, CET),
ZoneTransition(datetime(1993, 3, 28, 2), CET, CEST),
ZoneTransition(datetime(1993, 9, 26, 3), CEST, CET),
ZoneTransition(datetime(1996, 3, 31, 2), CET, WEST),
ZoneTransition(datetime(1996, 10, 27, 2), WEST, WET),
]
def _Europe_London():
LMT = ZoneOffset("LMT", timedelta(seconds=-75), ZERO)
GMT = ZoneOffset("GMT", ZERO, ZERO)
BST = ZoneOffset("BST", ONE_H, ONE_H)
return [
ZoneTransition(datetime(1847, 12, 1), LMT, GMT),
ZoneTransition(datetime(2005, 3, 27, 1), GMT, BST),
ZoneTransition(datetime(2005, 10, 30, 2), BST, GMT),
ZoneTransition(datetime(2043, 3, 29, 1), GMT, BST),
ZoneTransition(datetime(2043, 10, 25, 2), BST, GMT),
]
def _Pacific_Kiritimati():
LMT = ZoneOffset("LMT", timedelta(seconds=-37760), ZERO)
N1040 = ZoneOffset("-1040", timedelta(seconds=-38400), ZERO)
N10 = ZoneOffset("-10", timedelta(seconds=-36000), ZERO)
P14 = ZoneOffset("+14", timedelta(seconds=50400), ZERO)
# This is literally every transition in Christmas Island history
return [
ZoneTransition(datetime(1901, 1, 1), LMT, N1040),
ZoneTransition(datetime(1979, 10, 1), N1040, N10),
# They skipped December 31, 1994
ZoneTransition(datetime(1994, 12, 31), N10, P14),
]
return {
"Africa/Abidjan": _Africa_Abidjan(),
"Africa/Casablanca": _Africa_Casablanca(),
"America/Los_Angeles": _America_Los_Angeles(),
"America/Santiago": _America_Santiago(),
"Australia/Sydney": _Australia_Sydney(),
"Asia/Tokyo": _Asia_Tokyo(),
"Europe/Dublin": _Europe_Dublin(),
"Europe/Lisbon": _Europe_Lisbon(),
"Europe/London": _Europe_London(),
"Pacific/Kiritimati": _Pacific_Kiritimati(),
}
return _zone_dump_data()
@functools.cache
def transition_examples():
zonedump_data = get_zonedump_data()
return tuple(zonedump_data.items())
####
# Tests
@set_tzpath((), block_tzdata=True)
def test_no_tz_data():
"""Test what happens when no TZ data is available."""
tz.gettz.cache_clear()
NYC = tz.gettz("America/New_York")
assert NYC is None
def test_tzpath_setting(tmp_path):
"""Ensure that setting tz.TZPATH changes where `tz.gettz` searches."""
with set_tzpath([tmp_path]):
_copy_resource_to(
"liliput_tzif", os.path.join(str(tmp_path), "Fictional", "Liliput")
)
tz.gettz.cache_clear()
fiction_land = tz.gettz("Fictional/Liliput")
assert fiction_land is not None
@pytest.mark.parametrize(
"dt, errtype",
[
# Should fail if tzinfo is not `self`
(datetime(2019, 1, 1, tzinfo=tz.UTC), ValueError),
(datetime(2019, 1, 1), ValueError),
# Only works with `datetime`
(date(2019, 1, 1), TypeError),
(time(0), TypeError),
(0, TypeError),
("2019-01-01", TypeError),
],
)
def test_fromutc_errors(dt, errtype):
"""tzinfo.fromutc invocations that raise an error."""
zone = tz.gettz("Europe/London") # Any zone should work
with pytest.raises(errtype):
zone.fromutc(dt)
@as_list
def _get_unambiguous_transitions():
for key, zone_transitions in transition_examples():
for zone_transition in zone_transitions:
# key, datetime, fold, offset
yield (
key,
zone_transition.transition - timedelta(days=2),
0,
zone_transition.offset_before,
)
yield (
key,
zone_transition.transition + timedelta(days=2),
0,
zone_transition.offset_after,
)
@pytest.mark.parametrize("zoneinfo_cache", ["v1", "slim", "fat"], indirect=True)
@pytest.mark.parametrize(
"transition", _get_unambiguous_transitions(), indirect=True
)
def test_unambiguous(transition):
"""Test times that are *not* ambiguous."""
tzi = tz.gettz(transition.key)
dt = transition.dt.replace(tzinfo=tzi)
assert dt.tzname() == transition.offset.tzname
assert dt.utcoffset() == transition.offset.utcoffset
assert dt.dst() == transition.offset.dst
@as_list
def _get_folds_and_gaps():
for key, zone_transitions in transition_examples():
for zt in zone_transitions:
if not zt.fold and not zt.gap:
continue
test_group = "fold" if zt.fold else "gap"
# Cases are of the form key, dt, fold, offset
dt = zt.anomaly_start - timedelta(seconds=1)
yield (key, dt, 0, zt.offset_before)
yield (key, dt, 1, zt.offset_before)
dt = zt.anomaly_start
yield (key, dt, 0, zt.offset_before)
yield (key, dt, 1, zt.offset_after)
dt = zt.anomaly_start + timedelta(seconds=1)
yield (key, dt, 0, zt.offset_before)
yield (key, dt, 1, zt.offset_after)
dt = zt.anomaly_end - timedelta(seconds=1)
yield (key, dt, 0, zt.offset_before)
yield (key, dt, 1, zt.offset_after)
dt = zt.anomaly_end
yield (key, dt, 0, zt.offset_after)
yield (key, dt, 1, zt.offset_after)
dt = zt.anomaly_end + timedelta(seconds=1)
yield (key, dt, 0, zt.offset_after)
yield (key, dt, 1, zt.offset_after)
@pytest.mark.parametrize("zoneinfo_cache", ["v1", "slim", "fat"], indirect=True)
@pytest.mark.parametrize("transition", _get_folds_and_gaps(), indirect=True)
def test_gaps_and_folds(transition):
"""Test times that are ambiguous."""
tzi = tz.gettz(transition.key)
dt = tz.enfold(transition.dt.replace(tzinfo=tzi), transition.fold)
assert dt.tzname() == transition.offset.tzname
assert dt.utcoffset() == transition.offset.utcoffset
assert dt.dst() == transition.offset.dst
@as_list
def _get_folds_from_utc():
for key, zone_transitions in transition_examples():
for zt in zone_transitions:
if not zt.fold:
continue
dt_utc = zt.transition_utc
# key, dt_utc, expected_fold, offset
yield (key, dt_utc - SECOND, 0, None)
yield (key, dt_utc + SECOND, 1, None)
@pytest.mark.parametrize("zoneinfo_cache", ["v1", "slim", "fat"], indirect=True)
@pytest.mark.parametrize("transition", _get_folds_from_utc(), indirect=True)
def test_folds_from_utc(transition):
tzi = tz.gettz(transition.key)
dt = transition.dt.astimezone(tzi)
assert dt.fold == transition.fold
def test_time_fixed_offset():
utc = tz.gettz("UTC")
assert isinstance(utc, tz.tzfile)
t = time(11, 1, tzinfo=utc)
assert t.utcoffset() == ZERO
def test_time_varying_offset():
tzi = tz.gettz("America/New_York")
t = time(11, 1, tzinfo=tzi)
assert t.utcoffset() is None
assert t.tzname() is None
assert t.dst() is None
@functools.cache
def weirdzone_test_cases():
_real_cases = {"offset": [], "utc": [], "varying_zones": []}
cases = {}
def add_cases(f):
cases["offset"] = []
cases["utc"] = []
cases["varying_zones"] = []
f()
case_found = False
for key, values in cases.items():
case_found = True
id_base = f.__name__.strip("_") + "-%s"
for i, value in enumerate(values):
_real_cases[key].append(pytest.param(*value, id=id_base % i))
if not case_found: # pragma: nocover
raise ValueError("%s did not add any tests!" % f)
@add_cases
def _one_transition():
LMT = ZoneOffset("LMT", -timedelta(hours=6, minutes=31, seconds=2))
STD = ZoneOffset("STD", -timedelta(hours=6))
transitions = [
ZoneTransition(datetime(1883, 6, 9, 14), LMT, STD),
]
after = "STD6"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/One_Transition")
cases["varying_zones"].append((zi,))
dt0 = datetime(1883, 6, 9, 1, tzinfo=zi)
dt1 = datetime(1883, 6, 10, 1, tzinfo=zi)
for dt, offset in [(dt0, LMT), (dt1, STD)]:
dt = dt.replace(tzinfo=zi)
cases["offset"].append((dt, offset))
dts = [
(
datetime(1883, 6, 9, 1),
datetime(1883, 6, 9, 7, 31, 2, tzinfo=tz.UTC),
),
(
datetime(2010, 4, 1, 12),
datetime(2010, 4, 1, 18, tzinfo=tz.UTC),
),
]
for dt_naive, dt_utc in dts:
cases["utc"].append((dt_naive.replace(tzinfo=zi), dt_utc))
@add_cases
def _one_transition_fold():
# A zone whose only transition moves the offset backwards, so the
# fold it creates has no earlier transition to compare against.
AAA = ZoneOffset("AAA", ZERO)
BBB = ZoneOffset("BBB", -3 * ONE_H)
transitions = [
ZoneTransition(datetime(1976, 12, 1), AAA, BBB),
]
after = "BBB3"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/One_Transition_Fold")
cases["varying_zones"].append((zi,))
dts = [
(datetime(1976, 11, 30, 20), datetime(1976, 11, 30, 20), 0),
(datetime(1976, 11, 30, 22), datetime(1976, 11, 30, 22), 0),
(datetime(1976, 11, 30, 21), datetime(1976, 12, 1, 0), 1),
(datetime(1976, 11, 30, 22), datetime(1976, 12, 1, 1), 1),
(datetime(1976, 11, 30, 23, 59), datetime(1976, 12, 1, 2, 59), 1),
(datetime(1976, 12, 1), datetime(1976, 12, 1, 3), 0),
(datetime(2020, 1, 1), datetime(2020, 1, 1, 3), 0),
]
for dt_naive, dt_utc, fold in dts:
dt = tz.enfold(dt_naive.replace(tzinfo=zi), fold=fold)
cases["utc"].append((dt, dt_utc.replace(tzinfo=tz.UTC)))
@add_cases
def _last_transition_fold_before_tzstr():
# Like America/Ciudad_Juarez in slim builds: the last explicit
# transition moves the offset backwards, and the TZ string that
# takes over after it has a DST rule of its own.
LMT = ZoneOffset("LMT", -timedelta(hours=7))
CST = ZoneOffset("CST", -6 * ONE_H)
MST = ZoneOffset("MST", -7 * ONE_H)
MDT = ZoneOffset("MDT", -6 * ONE_H, ONE_H)
transitions = [
ZoneTransition(datetime(2000, 1, 1), LMT, CST),
ZoneTransition(datetime(2022, 11, 30), CST, MST),
]
after = "MST7MDT,M3.2.0,M11.1.0"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/Last_Transition_Fold")
cases["varying_zones"].append((zi,))
dts = [
(datetime(2022, 11, 29, 23, 30), datetime(2022, 11, 30, 5, 30), 0),
(datetime(2022, 11, 29, 23), datetime(2022, 11, 30, 6), 1),
(datetime(2022, 11, 29, 23, 30), datetime(2022, 11, 30, 6, 30), 1),
(datetime(2022, 11, 30, 0, 30), datetime(2022, 11, 30, 7, 30), 0),
(datetime(2023, 7, 1, 12), datetime(2023, 7, 1, 18), 0),
]
for dt_naive, dt_utc, fold in dts:
dt = tz.enfold(dt_naive.replace(tzinfo=zi), fold=fold)
cases["utc"].append((dt, dt_utc.replace(tzinfo=tz.UTC)))
@add_cases
def _last_transition_inside_tzstr_dst():
# Like America/Nuuk in slim builds: the last explicit transition is a
# change from DST to standard time with the same offset, which falls
# inside a DST period that the TZ string would otherwise give.
STD3 = ZoneOffset("-03", -3 * ONE_H)
DST2 = ZoneOffset("-02", -2 * ONE_H, ONE_H)
STD2 = ZoneOffset("-02", -2 * ONE_H)
transitions = [
ZoneTransition(datetime(2023, 3, 25, 22), STD3, DST2),
ZoneTransition(datetime(2023, 10, 28, 23), DST2, STD2),
]
after = "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/Last_Transition_Inside_TZStr_DST")
cases["varying_zones"].append((zi,))
for dt, offset in [
(datetime(2023, 10, 28, 22, 30), DST2),
(datetime(2023, 10, 28, 23), STD2),
(datetime(2023, 10, 28, 23, 30), STD2),
(datetime(2023, 10, 29, 1), STD2),
]:
for fold in (0, 1):
dt_fold = tz.enfold(dt.replace(tzinfo=zi), fold=fold)
cases["offset"].append((dt_fold, offset))
dts = [
(datetime(2023, 10, 28, 22, 30), datetime(2023, 10, 29, 0, 30)),
(datetime(2023, 10, 28, 23), datetime(2023, 10, 29, 1)),
(datetime(2023, 10, 28, 23, 30), datetime(2023, 10, 29, 1, 30)),
(datetime(2023, 10, 29, 1), datetime(2023, 10, 29, 3)),
# From here on, the TZ string applies
(datetime(2024, 7, 1, 12), datetime(2024, 7, 1, 13)),
]
for dt_naive, dt_utc in dts:
cases["utc"].append(
(dt_naive.replace(tzinfo=zi), dt_utc.replace(tzinfo=tz.UTC))
)
@add_cases
def _last_transition_same_offset_as_tzstr():
# Like Africa/Casablanca in the rearguard format: the last explicit
# transition goes to +01 as DST, and the TZ string that follows it
# describes +01 as (negative) DST from a standard offset of +02. The
# offset is the same either way, so times in the gap before it get
# the TZ string's answer, the same as the times after the gap.
STD = ZoneOffset("+00", ZERO)
DST = ZoneOffset("+01", ONE_H, ONE_H)
AFTER = ZoneOffset("+01", ONE_H, -ONE_H)
transitions = [
ZoneTransition(datetime(2087, 3, 1, 3), DST, STD),
ZoneTransition(datetime(2087, 5, 11, 2), STD, DST),
]
after = "XXX-2<+01>-1,0/0,J365/23"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/Last_Transition_Same_Offset_As_TZStr")
cases["varying_zones"].append((zi,))
for dt, fold, offset in [
(datetime(2087, 5, 11, 1, 30), 0, STD),
(datetime(2087, 5, 11, 2, 30), 0, STD),
(datetime(2087, 5, 11, 2, 30), 1, AFTER),
(datetime(2087, 5, 11, 3, 30), 0, AFTER),
]:
cases["offset"].append(
(tz.enfold(dt.replace(tzinfo=zi), fold), offset)
)
@add_cases
def _one_transition_zone_dst():
DST = ZoneOffset("DST", ONE_H, ONE_H)
transitions = [
ZoneTransition(datetime(1970, 1, 1), DST, DST),
]
after = "STD0DST-1,0/0,J365/25"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/One_Zone_DST")
cases["varying_zones"].append((zi,))
dts = [
datetime(1900, 3, 1),
datetime(1965, 9, 12),
datetime(1970, 1, 1),
datetime(2010, 11, 3),
datetime(2040, 1, 1),
]
for dt in dts:
dt = dt.replace(tzinfo=zi)
cases["offset"].append((dt, DST))
@add_cases
def _no_tzstr_zone():
STD = ZoneOffset("STD", ONE_H, ZERO)
DST = ZoneOffset("DST", 2 * ONE_H, ONE_H)
transitions = []
for year in range(1996, 2000):
transitions.append(
ZoneTransition(datetime(year, 3, 1, 2), STD, DST)
)
transitions.append(
ZoneTransition(datetime(year, 11, 1, 2), DST, STD)
)
after = ""
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/No_TzStr")
cases["varying_zones"].append((zi,))
dts = [
(datetime(1995, 1, 1), STD),
(datetime(1996, 4, 1), DST),
(datetime(1996, 11, 2), STD),
(datetime(2001, 1, 1), STD),
]
for dt, offset in dts:
dt = dt.replace(tzinfo=zi)
cases["offset"].append((dt, offset))
@add_cases
def _tz_no_transitions_before_only():
# From RFC 8536 Section 3.2:
#
# If there are no transitions, local time for all timestamps is
# specified by the TZ string in the footer if present and nonempty;
# otherwise, it is specified by time type 0.
offsets = [
ZoneOffset("STD", ZERO, ZERO),
ZoneOffset("DST", ONE_H, ONE_H),
]
for offset in offsets:
# Phantom transition to set time type 0.
transitions = [
ZoneTransition(None, offset, offset),
]
after = ""
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/No_Transitions_%s" % offset.tzname)
dts = [
datetime(1900, 1, 1),
datetime(1970, 1, 1),
datetime(2000, 1, 1),
time(12, 0, 0),
]
for dt in dts:
cases["offset"].append((dt.replace(tzinfo=zi), offset))
@add_cases
def _very_large_timestamp_zone():
"""Tests using a zone with transitions very far in the past and future.
Particularly, this is a concern if something:
1. Attempts to call ``datetime.timestamp`` for a datetime outside
of ``[datetime.min, datetime.max]``.
2. Attempts to construct a timedelta outside of
``[timedelta.min, timedelta.max]``.
This actually occurs "in the wild", as some time zones on Ubuntu (at
least as of 2020) have an initial transition added at ``-2**58``.
"""
LMT = ZoneOffset("LMT", timedelta(seconds=-968))
GMT = ZoneOffset("GMT", ZERO)
transitions = [
(-(1 << 62), LMT, LMT),
ZoneTransition(datetime(1912, 1, 1), LMT, GMT),
((1 << 62), GMT, GMT),
]
after = "GMT0"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/Large_Transitions")
cases["varying_zones"].append((zi,))
offset_cases = [
(datetime.min, LMT),
(datetime.max, GMT),
(datetime(1911, 12, 31), LMT),
(datetime(1912, 1, 2), GMT),
]
for dt_naive, offset in offset_cases:
dt = dt_naive.replace(tzinfo=zi)
cases["offset"].append((dt, offset))
utc_cases = [
(datetime.min, datetime.min + timedelta(seconds=968)),
(datetime(1898, 12, 31, 23, 43, 52), datetime(1899, 1, 1)),
(
datetime(1911, 12, 31, 23, 59, 59, 999999),
datetime(1912, 1, 1, 0, 16, 7, 999999),
),
(
datetime(1912, 1, 1, 0, 16, 8),
(datetime(1912, 1, 1, 0, 16, 8)),
),
(datetime(1970, 1, 1), datetime(1970, 1, 1)),
(datetime.max, datetime.max),
]
for naive_dt, naive_dt_utc in utc_cases:
dt = naive_dt.replace(tzinfo=zi)
dt_utc = naive_dt_utc.replace(tzinfo=tz.UTC)
cases["utc"].append((dt, dt_utc))
@add_cases
def _fixed_offset_phantom_transition_zone():
"""A zone with a phantom transition from UTC -> UTC."""
UTC = ZoneOffset("UTC", ZERO, ZERO)
transitions = [ZoneTransition(datetime(1970, 1, 1), UTC, UTC)]
after = "UTC0"
zf = construct_zone(transitions, after)
zi = tz.tzfile(zf, key="Etc/Phantom_UTC")
cases["offset"].append((datetime(2020, 1, 1, tzinfo=zi), UTC))
cases["offset"].append((time(0, tzinfo=zi), UTC))
NORMAL = 0
FOLD = 1
GAP = 2
def add_tzstr_tests(tzstr, test_cases, varying=True):
zi = zone_from_tzstr(tzstr)
if varying:
cases["varying_zones"].append((zi,))
for dt_naive, offset, dt_type in test_cases:
dt = dt_naive.replace(tzinfo=zi)
cases["offset"].append((dt, offset))
if dt_type == GAP:
continue
dt_utc = (dt_naive - offset.utcoffset).replace(tzinfo=tz.UTC)
cases["utc"].append((dt, dt_utc))
@add_cases
def _tzstr_standard_est5edt():
# Transition to EDT on the 2nd Sunday in March at 4 AM, and
# transition back on the first Sunday in November at 3AM
tzstr = "EST5EDT,M3.2.0/4:00,M11.1.0/3:00"
EST = ZoneOffset("EST", timedelta(hours=-5), ZERO)
EDT = ZoneOffset("EDT", timedelta(hours=-4), ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2019, 3, 9), EST, NORMAL),
(datetime(2019, 3, 10, 3, 59), EST, NORMAL),
(datetime(2019, 3, 10, 4, 0), EST, GAP),
(tz.enfold(datetime(2019, 3, 10, 4, 0), 1), EDT, GAP),
(tz.enfold(datetime(2019, 3, 10, 4, 1), 0), EST, GAP),
(tz.enfold(datetime(2019, 3, 10, 4, 1), 1), EDT, GAP),
(datetime(2019, 11, 2), EDT, NORMAL),
(datetime(2019, 11, 3, 1, 59), EDT, NORMAL),
(datetime(2019, 11, 3, 2, 0), EDT, FOLD),
(tz.enfold(datetime(2019, 11, 3, 2, 0), 1), EST, FOLD),
(datetime(2020, 3, 8, 3, 59), EST, NORMAL),
(datetime(2020, 3, 8, 4, 0), EST, GAP),
(tz.enfold(datetime(2020, 3, 8, 4, 0), 1), EDT, GAP),
(tz.enfold(datetime(2020, 11, 1, 1, 59), 1), EDT, NORMAL),
(tz.enfold(datetime(2020, 11, 1, 2, 0), 0), EDT, FOLD),
(tz.enfold(datetime(2020, 11, 1, 2, 0), 1), EST, FOLD),
),
)
@add_cases
def _tzstr_gmt0bst1():
# Transition to BST happens on the last Sunday in March at 1 AM GMT
# and the transition back happens the last Sunday in October at 2AM BST
tzstr = "GMT0BST-1,M3.5.0/1:00,M10.5.0/2:00"
GMT = ZoneOffset("GMT", ZERO, ZERO)
BST = ZoneOffset("BST", ONE_H, ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2019, 3, 30), GMT, NORMAL),
(datetime(2019, 3, 31, 0, 59), GMT, NORMAL),
(datetime(2019, 3, 31, 2, 0), BST, NORMAL),
(datetime(2019, 10, 26), BST, NORMAL),
(tz.enfold(datetime(2019, 10, 27, 0, 59), 1), BST, NORMAL),
(tz.enfold(datetime(2019, 10, 27, 1, 0), 0), BST, GAP),
(tz.enfold(datetime(2019, 10, 27, 2, 0), 1), GMT, GAP),
(datetime(2020, 3, 29, 0, 59), GMT, NORMAL),
(datetime(2020, 3, 29, 2, 0), BST, NORMAL),
(tz.enfold(datetime(2020, 10, 25, 0, 59), 1), BST, NORMAL),
(tz.enfold(datetime(2020, 10, 25, 1, 0), 0), BST, FOLD),
(tz.enfold(datetime(2020, 10, 25, 2, 0), 1), GMT, NORMAL),
),
)
@add_cases
def _tzstr_aest10_aedt():
# Austrialian time zone - DST start is chronologically first
tzstr = "AEST-10AEDT,M10.1.0/2,M4.1.0/3"
AEST = ZoneOffset("AEST", timedelta(hours=10), ZERO)
AEDT = ZoneOffset("AEDT", timedelta(hours=11), ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2019, 4, 6), AEDT, NORMAL),
(datetime(2019, 4, 7, 1, 59), AEDT, NORMAL),
(tz.enfold(datetime(2019, 4, 7, 1, 59), 1), AEDT, NORMAL),
(tz.enfold(datetime(2019, 4, 7, 2, 0), 0), AEDT, FOLD),
(tz.enfold(datetime(2019, 4, 7, 2, 1), 0), AEDT, FOLD),
(tz.enfold(datetime(2019, 4, 7, 2, 0), 1), AEST, FOLD),
(tz.enfold(datetime(2019, 4, 7, 2, 1), 1), AEST, FOLD),
(tz.enfold(datetime(2019, 4, 7, 3, 0), 0), AEST, NORMAL),
(tz.enfold(datetime(2019, 4, 7, 3, 0), 1), AEST, NORMAL),
(datetime(2019, 10, 5, 0), AEST, NORMAL),
(datetime(2019, 10, 6, 1, 59), AEST, NORMAL),
(tz.enfold(datetime(2019, 10, 6, 2, 0), 0), AEST, GAP),
(tz.enfold(datetime(2019, 10, 6, 2, 0), 1), AEDT, GAP),
(datetime(2019, 10, 6, 3, 0), AEDT, NORMAL),
),
)
@add_cases
def _tzstr_negative_dst():
# Irish time zone - negative DST
tzstr = "IST-1GMT0,M10.5.0,M3.5.0/1"
GMT = ZoneOffset("GMT", ZERO, -ONE_H)
IST = ZoneOffset("IST", ONE_H, ZERO)
add_tzstr_tests(
tzstr,
(
(datetime(2019, 3, 30), GMT, NORMAL),
(datetime(2019, 3, 31, 0, 59), GMT, NORMAL),
(datetime(2019, 3, 31, 2, 0), IST, NORMAL),
(datetime(2019, 10, 26), IST, NORMAL),
(tz.enfold(datetime(2019, 10, 27, 0, 59), 1), IST, NORMAL),
(tz.enfold(datetime(2019, 10, 27, 1, 0), 0), IST, FOLD),
(tz.enfold(datetime(2019, 10, 27, 1, 0), 1), GMT, FOLD),
(tz.enfold(datetime(2019, 10, 27, 2, 0), 1), GMT, NORMAL),
(datetime(2020, 3, 29, 0, 59), GMT, NORMAL),
(datetime(2020, 3, 29, 2, 0), IST, NORMAL),
(tz.enfold(datetime(2020, 10, 25, 0, 59), 1), IST, NORMAL),
(tz.enfold(datetime(2020, 10, 25, 1, 0), 0), IST, FOLD),
(tz.enfold(datetime(2020, 10, 25, 2, 0), 1), GMT, NORMAL),
),
)
@add_cases
def _tzstr_fixed_offset_quoted_numerical():
# Pacific/Kosrae: Fixed offset zone with a quoted numerical tzname
tzstr = "<+11>-11"
add_tzstr_tests(
tzstr,
(
(
datetime(2020, 1, 1),
ZoneOffset("+11", timedelta(hours=11)),
NORMAL,
),
),
varying=False,
)
@add_cases
def _tzstr_quoted_std_and_dst_trans_at_24():
# Quoted STD and DST, transitions at 24:00
tzstr = "<-04>4<-03>,M9.1.6/24,M4.1.6/24"
M04 = ZoneOffset("-04", timedelta(hours=-4))
M03 = ZoneOffset("-03", timedelta(hours=-3), ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2020, 5, 1), M04, NORMAL),
(datetime(2020, 11, 1), M03, NORMAL),
),
)
@add_cases
def _tzstr_permanent_dst():
# Permanent daylight saving time is modeled with transitions at 0/0
# and J365/25, as mentioned in RFC 8536 Section 3.3.1
tzstr = "EST5EDT,0/0,J365/25"
EDT = ZoneOffset("EDT", timedelta(hours=-4), ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2019, 1, 1), EDT, NORMAL),
(datetime(2019, 6, 1), EDT, NORMAL),
(datetime(2019, 12, 31, 23, 59, 59, 999999), EDT, NORMAL),
(datetime(2020, 1, 1), EDT, NORMAL),
(datetime(2020, 3, 1), EDT, NORMAL),
(datetime(2020, 6, 1), EDT, NORMAL),
(datetime(2020, 12, 31, 23, 59, 59, 999999), EDT, NORMAL),
(datetime(2400, 1, 1), EDT, NORMAL),
(datetime(2400, 3, 1), EDT, NORMAL),
(datetime(2400, 12, 31, 23, 59, 59, 999999), EDT, NORMAL),
),
)
@add_cases
def _tzstr_transitions_on_julian_day():
# Transitions on March 1st and November 1st of each year
tzstr = "AAA3BBB,J60/12,J305/12"
AAA = ZoneOffset("AAA", timedelta(hours=-3))
BBB = ZoneOffset("BBB", timedelta(hours=-2), ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2019, 1, 1), AAA, NORMAL),
(datetime(2019, 2, 28), AAA, NORMAL),
(datetime(2019, 3, 1, 11, 59), AAA, NORMAL),
(tz.enfold(datetime(2019, 3, 1, 12), 0), AAA, GAP),
(tz.enfold(datetime(2019, 3, 1, 12), 1), BBB, GAP),
(datetime(2019, 3, 1, 13), BBB, NORMAL),
(datetime(2019, 11, 1, 10, 59), BBB, NORMAL),
(tz.enfold(datetime(2019, 11, 1, 11), 0), BBB, FOLD),
(tz.enfold(datetime(2019, 11, 1, 11), 1), AAA, FOLD),
(datetime(2019, 11, 1, 12), AAA, NORMAL),
(datetime(2019, 12, 31, 23, 59, 59, 999999), AAA, NORMAL),
(datetime(2020, 1, 1), AAA, NORMAL),
(datetime(2020, 2, 29), AAA, NORMAL),
(datetime(2020, 3, 1, 11, 59), AAA, NORMAL),
(tz.enfold(datetime(2020, 3, 1, 12), 0), AAA, GAP),
(tz.enfold(datetime(2020, 3, 1, 12), 1), BBB, GAP),
(datetime(2020, 3, 1, 13), BBB, NORMAL),
(datetime(2020, 11, 1, 10, 59), BBB, NORMAL),
(tz.enfold(datetime(2020, 11, 1, 11), 0), BBB, FOLD),
(tz.enfold(datetime(2020, 11, 1, 11), 1), AAA, FOLD),
(datetime(2020, 11, 1, 12), AAA, NORMAL),
(datetime(2020, 12, 31, 23, 59, 59, 999999), AAA, NORMAL),
),
)
@add_cases
def _tzstr_negative_transition_times():
# Taken from America/Godthab, this rule has a transition on the
# Saturday before the last Sunday of March and October, at 22:00
# and 23:00, respectively. This is encoded with negative start
# and end transition times.
tzstr = "<-03>3<-02>,M3.5.0/-2,M10.5.0/-1"
N03 = ZoneOffset("-03", timedelta(hours=-3))
N02 = ZoneOffset("-02", timedelta(hours=-2), ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2020, 3, 27), N03, NORMAL),
(datetime(2020, 3, 28, 21, 59, 59), N03, NORMAL),
(tz.enfold(datetime(2020, 3, 28, 22), 0), N03, GAP),
(tz.enfold(datetime(2020, 3, 28, 22), 1), N02, GAP),
(datetime(2020, 3, 28, 23), N02, NORMAL),
(datetime(2020, 10, 24, 21), N02, NORMAL),
(tz.enfold(datetime(2020, 10, 24, 22), 0), N02, FOLD),
(tz.enfold(datetime(2020, 10, 24, 22), 1), N03, FOLD),
(datetime(2020, 10, 24, 23), N03, NORMAL),
),
)
@add_cases
def _tzstr_minute_second_transition_times():
# Transition times with minutes and seconds
tzstr = "AAA3BBB,M3.2.0/01:30,M11.1.0/02:15:45"
AAA = ZoneOffset("AAA", timedelta(hours=-3))
BBB = ZoneOffset("BBB", timedelta(hours=-2), ONE_H)
add_tzstr_tests(
tzstr,
(
(datetime(2012, 3, 11, 1, 0), AAA, NORMAL),
(tz.enfold(datetime(2012, 3, 11, 1, 30), 0), AAA, GAP),
(tz.enfold(datetime(2012, 3, 11, 1, 30), 1), BBB, GAP),
(datetime(2012, 3, 11, 2, 30), BBB, NORMAL),
(datetime(2012, 11, 4, 1, 15, 44, 999999), BBB, NORMAL),
(tz.enfold(datetime(2012, 11, 4, 1, 15, 45), 0), BBB, FOLD),
(tz.enfold(datetime(2012, 11, 4, 1, 15, 45), 1), AAA, FOLD),
(datetime(2012, 11, 4, 2, 15, 45), AAA, NORMAL),
),
)
return _real_cases
def _fixed_offset_zone():
STD = ZoneOffset("STD", -5 * ONE_H)
return tz.tzfile(
construct_zone([ZoneTransition(datetime(2020, 1, 1), STD, STD)], "STD5")
)
@pytest.mark.parametrize(
"dt",
[
datetime(2019, 12, 31, 23, 30),
datetime(2020, 1, 1),
datetime(2020, 1, 1, 0, 30),
datetime(2100, 1, 1),
],
)
def test_fixed_offset_never_ambiguous_or_imaginary(dt):
"""A fixed-offset zone has no ambiguous or imaginary times."""
zone = _fixed_offset_zone()
for fold in (0, 1):
dt_fold = tz.enfold(dt.replace(tzinfo=zone), fold=fold)
assert not zone.is_ambiguous(dt_fold)
assert not tz.datetime_ambiguous(dt_fold)
assert tz.datetime_exists(dt_fold)
assert dt_fold.utcoffset() == -5 * ONE_H
@pytest.mark.parametrize(
"zone",
[
pytest.param(_fixed_offset_zone(), id="fixed"),
pytest.param(
tz.tzfile(
construct_zone(
[
ZoneTransition(
datetime(2020, 11, 1, 2),
ZoneOffset("DST", -4 * ONE_H, ONE_H),
ZoneOffset("STD", -5 * ONE_H),
)
],
"STD5",
)
),
id="varying",
),
],
)
def test_is_ambiguous_none(zone):
"""is_ambiguous(None) is False, as it is for other tzinfo classes."""
assert zone.is_ambiguous(None) is False
@pytest.mark.parametrize("dt, offset", weirdzone_test_cases()["offset"])
def test_weirdzone_offsets(dt, offset):
assert dt.tzname() == offset.tzname
assert dt.utcoffset() == offset.utcoffset
assert dt.dst() == offset.dst
@pytest.mark.parametrize("dt, dt_utc", weirdzone_test_cases()["utc"])
def test_weirdzone_utc(dt, dt_utc):
assert dt.tzinfo is not None
assert dt.astimezone(tz.UTC) == dt_utc
# Comparisons between datetimes with the same tzinfo ignore fold, so
# also check the round trip back to UTC and, for ambiguous times, fold.
dt_act = dt_utc.astimezone(dt.tzinfo)
assert dt_act == dt
assert dt_act.astimezone(tz.UTC) == dt_utc
if tz.datetime_ambiguous(dt):
assert dt_act.fold == dt.fold
@pytest.mark.parametrize("zone", weirdzone_test_cases()["varying_zones"])
def test_weirdzone_time_none(zone):
t = time(12, 0, 0, tzinfo=zone)
assert t.tzname() is None
assert t.utcoffset() is None
assert t.dst() is None
def test_empty_zone():
zf = construct_zone([], "")
with pytest.raises(ValueError):
tz.tzfile(zf)
@pytest.mark.parametrize("version", [1, 2, 3])
def test_no_transitions_uses_time_type_0(version):
"""Without transitions or a TZ string, local time is time type 0."""
STD = ZoneOffset("STD", ZERO)
ALT = ZoneOffset("ALT", ONE_H, ONE_H)
# A transition without a time just adds the local time types to the file
zone = tz.tzfile(
construct_zone([ZoneTransition(None, STD, ALT)], "", version=version)
)
for dt in [
datetime(1900, 1, 1),
datetime(2020, 7, 1),
datetime(2100, 1, 1),
]:
dt = dt.replace(tzinfo=zone)
assert dt.utcoffset() == ZERO
assert dt.dst() == ZERO
assert dt.tzname() == "STD"
def test_eq_no_transitions():
"""Zones with no transitions must compare by their fixed offset."""
plus_five = zone_from_tzstr("<+05>-5")
minus_five = zone_from_tzstr("<-05>5")
utc = zone_from_tzstr("UTC0")
assert plus_five == zone_from_tzstr("<+05>-5")
assert plus_five != minus_five
assert plus_five != utc
assert minus_five != utc
def test_eq_same_transitions_different_tzstr():
"""Zones that differ only in the TZ string are not equal."""
STD = ZoneOffset("STD", ZERO)
DST = ZoneOffset("DST", ONE_H, ONE_H)
transitions = [ZoneTransition(datetime(2010, 3, 14, 2), STD, DST)]
with_rule = tz.tzfile(construct_zone(transitions, "STD0DST,M3.2.0,M11.1.0"))
same_rule = tz.tzfile(construct_zone(transitions, "STD0DST,M3.2.0,M11.1.0"))
no_rule = tz.tzfile(construct_zone(transitions, ""))
assert with_rule == same_rule
assert with_rule != no_rule
@pytest.mark.parametrize("strip", [1, 2, 4, 8])
def test_truncated_footer(strip):
"""A file cut off inside the TZ string footer must not hang."""
zf = construct_zone([], "EST5EDT,M3.2.0,M11.1.0")
data = zf.read()
assert data.endswith(b"\n")
with pytest.raises(ValueError):
tz.tzfile(io.BytesIO(data[:-strip]))
def _zone_for_stream_tests():
STD = ZoneOffset("STD", -5 * ONE_H)
DST = ZoneOffset("DST", -4 * ONE_H, ONE_H)
return construct_zone(
[
ZoneTransition(datetime(2020, 3, 8, 2), STD, DST),
ZoneTransition(datetime(2020, 11, 1, 2), DST, STD),
],
"STD5DST,M3.2.0,M11.1.0",
).read()
def test_tzfile_from_pipe():
"""A zone can be read from a stream that cannot seek."""
data = _zone_for_stream_tests()
expected = tz.tzfile(io.BytesIO(data))
read_fd, write_fd = os.pipe()
os.write(write_fd, data)
os.close(write_fd)
# On Windows, pipes claim to be seekable even though they aren't.
with open(read_fd, "rb") as f:
zone = tz.tzfile(f, filename="pipe")
assert zone == expected
assert datetime(2030, 7, 1, tzinfo=zone).tzname() == "DST"
def test_tzfile_from_read_only_object():
"""A zone can be read from an object that only has a read() method."""
class ReadOnly:
def __init__(self, data):
self._stream = io.BytesIO(data)
def read(self, *args):
return self._stream.read(*args)
data = _zone_for_stream_tests()
zone = tz.tzfile(ReadOnly(data), filename="read-only")
assert zone == tz.tzfile(io.BytesIO(data))
@pytest.fixture
def tzpath_and_outside_zone(tmp_path):
"""A TZPATH entry, and a zone in a sibling directory outside of it."""
STD = ZoneOffset("STD", ZERO)
data = construct_zone(
[ZoneTransition(datetime(2020, 1, 1), STD, STD)], "STD0"
).read()
tzpath = tmp_path / "zoneinfo"
outside = tmp_path / "outside"
for directory in (tzpath / "Fictional", outside):
os.makedirs(str(directory))
(tzpath / "Fictional" / "Inside").write_bytes(data)
(outside / "Outside").write_bytes(data)
with set_tzpath((str(tzpath),), block_tzdata=True):
tz.gettz.cache_clear()
yield
tz.gettz.cache_clear()
@pytest.mark.parametrize(
"key",
[
"../outside/Outside",
"Fictional/../../outside/Outside",
"./../outside/Outside",
],
)
def test_gettz_key_outside_tzpath_is_an_error(tzpath_and_outside_zone, key):
with warnings.catch_warnings():
warnings.resetwarnings()
with pytest.raises(tz.DeprecatedTzKeyWarning):
tz.gettz(key)
def test_gettz_key_outside_tzpath_can_be_allowed(tzpath_and_outside_zone):
with warnings.catch_warnings(record=True) as record:
warnings.resetwarnings()
warnings.simplefilter("always", category=tz.DeprecatedTzKeyWarning)
zone = tz.gettz("../outside/Outside")
assert isinstance(zone, tz.tzfile)
assert [w.category for w in record] == [tz.DeprecatedTzKeyWarning]
assert (
os.path.splitext(record[0].filename)[0] == os.path.splitext(__file__)[0]
)
@pytest.mark.parametrize(
"key",
["Fictional/Inside", "Fictional/../Fictional/Inside", "./Fictional/Inside"],
)
def test_gettz_key_inside_tzpath_does_not_warn(tzpath_and_outside_zone, key):
with warnings.catch_warnings():
warnings.resetwarnings()
warnings.simplefilter("error")
assert isinstance(tz.gettz(key), tz.tzfile)
@pytest.mark.parametrize("version", [1, 2, 3, 4])
@pytest.mark.parametrize(
"leap_seconds",
[
pytest.param((), id="no_leap_seconds"),
pytest.param(((78796800, 1), (94694401, 2)), id="leap_seconds"),
# Version 4 files may truncate the start of the leap second table, so
# that the first correction is not +1 or -1.
pytest.param(((1435708825, 26), (1483228826, 27)), id="truncated"),
],
)
@pytest.mark.parametrize("indicators", [False, True])
def test_leap_seconds_and_indicators(version, leap_seconds, indicators):
"""Leap second records and indicators don't change how a zone behaves."""
STD = ZoneOffset("STD", -5 * ONE_H)
DST = ZoneOffset("DST", -4 * ONE_H, ONE_H)
transitions = [
ZoneTransition(datetime(2020, 3, 8, 2), STD, DST),
ZoneTransition(datetime(2020, 11, 1, 2), DST, STD),
]
after = "STD5DST,M3.2.0,M11.1.0"
expected = tz.tzfile(construct_zone(transitions, after, version=version))
zone = tz.tzfile(
construct_zone(
transitions,
after,
version=version,
leap_seconds=leap_seconds,
indicators=indicators,
)
)
assert zone == expected
for dt_utc in [
datetime(2019, 12, 1, tzinfo=tz.UTC),
datetime(2020, 7, 1, tzinfo=tz.UTC),
datetime(2020, 11, 1, 5, 30, tzinfo=tz.UTC),
datetime(2020, 11, 1, 6, 30, tzinfo=tz.UTC),
datetime(2030, 7, 1, tzinfo=tz.UTC),
]:
dt = dt_utc.astimezone(zone)
dt_expected = dt_utc.astimezone(expected)
assert dt.replace(tzinfo=None) == dt_expected.replace(tzinfo=None)
assert dt.fold == dt_expected.fold
assert dt.tzname() == dt_expected.tzname()
assert dt.utcoffset() == dt_expected.utcoffset()
@pytest.mark.parametrize("version", [1, 2, 3, 4])
@pytest.mark.parametrize("indicators", [False, True])
def test_stream_position_after_leap_seconds(version, indicators):
"""Reading a TZif file leaves the stream at the end of the file."""
STD = ZoneOffset("STD", -5 * ONE_H)
DST = ZoneOffset("DST", -4 * ONE_H, ONE_H)
transitions = [
ZoneTransition(datetime(2020, 3, 8, 2), STD, DST),
ZoneTransition(datetime(2020, 11, 1, 2), DST, STD),
]
zonefile = construct_zone(
transitions,
"STD5DST,M3.2.0,M11.1.0",
version=version,
leap_seconds=((78796800, 1), (94694401, 2)),
indicators=indicators,
)
trailer = b"Data after the TZif file"
stream = io.BytesIO(zonefile.getvalue() + trailer)
tz.tzfile(stream)
assert stream.read() == trailer
@pytest.mark.parametrize("version", [1, 2, 3, 4])
def test_update_tzif_file_to_v1(tmp_path, version):
"""update_tzif converts TZif files of any version to Version 1."""
import asyncio
from .resources import update_tzif
STD = ZoneOffset("STD", -5 * ONE_H)
DST = ZoneOffset("DST", -4 * ONE_H, ONE_H)
transitions = [
ZoneTransition(datetime(2020, 3, 8, 2), STD, DST),
ZoneTransition(datetime(2020, 11, 1, 2), DST, STD),
]
def make_zone(version):
return construct_zone(
transitions,
"STD5DST,M3.2.0,M11.1.0",
version=version,
leap_seconds=((78796800, 1), (94694401, 2)),
indicators=True,
).getvalue()
in_path = tmp_path / "in"
out_path = tmp_path / "out"
in_path.write_bytes(make_zone(version))
asyncio.run(update_tzif._file_to_v1(in_path, out_path))
assert out_path.read_bytes() == make_zone(1)
def _assert_constant_offset_around_new_year(zone, offset, years):
for year in years:
base = datetime(year, 12, 31, 18, tzinfo=tz.UTC)
for minutes in range(0, 12 * 60, 15):
dt_utc = base + timedelta(minutes=minutes)
dt = dt_utc.astimezone(zone)
assert dt.utcoffset() == offset, dt_utc
assert dt.astimezone(tz.UTC) == dt_utc
for fold in (0, 1):
wall = tz.enfold(dt, fold=fold)
assert wall.utcoffset() == offset, (wall, fold)
assert tz.datetime_exists(wall)
assert not tz.datetime_ambiguous(wall)
@as_list
def _near_min_and_max_year_cases():
EST = -5 * ONE_H
EDT = -4 * ONE_H
STD = ZERO
DST = ONE_H
for dt in [
datetime(1, 1, 1, 0, 30),
datetime(1, 1, 1, 12),
datetime(1, 1, 5, 12),
datetime(9999, 12, 28, 6, 55),
datetime(9999, 12, 31, 12),
datetime(9999, 12, 31, 23, 30),
]:
yield "EST5EDT,M3.2.0,M11.1.0", dt, EST
yield "<+00>0<+01>,0/0,J365/25", dt, DST
# DST all year: the rules for year 0 end DST at 01:00 on January 1st
# of year 1, just as the rules for year 1 start it, so there's no gap.
yield "EST5EDT,0/0,J365/25", dt, EDT
# The rules for year 10000 start DST on 9999-12-31.
for dt, offset in [
(datetime(1, 1, 1, 12), DST),
(datetime(9999, 4, 10, 12), STD),
(datetime(9999, 12, 30, 12), STD),
(datetime(9999, 12, 31, 12), DST),
(datetime(9999, 12, 31, 23, 30), DST),
]:
yield "STD0DST,J1/-24,J100/0", dt, offset
@pytest.mark.parametrize("tz_str, dt, offset", _near_min_and_max_year_cases())
def test_tzstr_near_min_and_max_year(tz_str, dt, offset):
"""The rules for years 0 and 10000 apply to the dates around them."""
zone = tz.tzfile(construct_zone([], tz_str))
for fold in (0, 1):
dt_fold = tz.enfold(dt.replace(tzinfo=zone), fold=fold)
assert dt_fold.utcoffset() == offset, fold
try:
dt_utc = (dt - offset).replace(tzinfo=tz.UTC)
except OverflowError:
# The UTC time is past the end of what datetime can represent
return
dt_local = dt_utc.astimezone(zone)
assert dt_local.replace(tzinfo=None) == dt
assert dt_local.utcoffset() == offset
@pytest.mark.parametrize(
"tz_str",
[
# DST all year, written the way zic writes it: each year's DST period
# ends at the same instant as the next one starts.
"<+00>0<+01>,J1/0,J365/25",
# The same, with negative DST, as in the rearguard format for
# Africa/Casablanca.
"XXX-2<+01>-1,J1/0,J365/23",
# The same two, spelled the way zic actually writes them
"<+00>0<+01>,0/0,J365/25",
"XXX-2<+01>-1,0/0,J365/23",
],
)
def test_tzstr_dst_all_year(tz_str):
"""Rules whose transitions meet at the new year don't create a gap."""
zone = tz.tzfile(construct_zone([], tz_str))
_assert_constant_offset_around_new_year(zone, ONE_H, (2023, 2024, 2100))
@pytest.mark.parametrize(
"tz_str, std_dts, dst_dts",
[
# "n" counts from 0, so day 10 is January 11th
(
"STD0DST-1,10/0,J100/0",
[datetime(2021, 1, 10, 12)],
[datetime(2021, 1, 11, 12)],
),
# ... and it includes February 29th: day 59 is February 29th in leap
# years and March 1st otherwise
(
"STD0DST-1,59/0,300/0",
[datetime(2024, 2, 28, 12), datetime(2023, 2, 28, 12)],
[datetime(2024, 2, 29, 12), datetime(2023, 3, 1, 12)],
),
# "Jn" counts from 1 and never counts February 29th, so J59 is always
# February 28th and J60 is always March 1st
(
"STD0DST-1,J59/0,J300/0",
[datetime(2024, 2, 27, 12), datetime(2023, 2, 27, 12)],
[datetime(2024, 2, 28, 12), datetime(2023, 2, 28, 12)],
),
(
"STD0DST-1,J60/0,J300/0",
[datetime(2024, 2, 29, 12), datetime(2023, 2, 28, 12)],
[datetime(2024, 3, 1, 12), datetime(2023, 3, 1, 12)],
),
],
)
def test_tzstr_day_of_year_rules(tz_str, std_dts, dst_dts):
"""The dates of "n" and "Jn" rules match POSIX (and glibc)."""
zone = tz.tzfile(construct_zone([], tz_str))
for dts, tzname in ((std_dts, "STD"), (dst_dts, "DST")):
for dt in dts:
assert dt.replace(tzinfo=zone).tzname() == tzname, dt
dt_utc = dt.replace(tzinfo=tz.UTC)
assert dt_utc.astimezone(zone).tzname() == tzname, dt_utc
@pytest.mark.parametrize("version", [1, 2, 3])
def test_truncated_file(version):
"""A file cut off anywhere is either still valid or raises ValueError."""
STD = ZoneOffset("STD", -5 * ONE_H)
DST = ZoneOffset("DST", -4 * ONE_H, ONE_H)
zf = construct_zone(
[
ZoneTransition(datetime(2020, 3, 8, 2), STD, DST),
ZoneTransition(datetime(2020, 11, 1, 2), DST, STD),
],
"STD5DST,M3.2.0,M11.1.0",
version=version,
)
data = zf.read()
for length in range(len(data)):
try:
tz.tzfile(io.BytesIO(data[:length]))
except ValueError:
pass
def test_truncated_file_on_tzpath(tmp_path):
"""gettz skips a truncated file on TZPATH like any other invalid file."""
STD = ZoneOffset("STD", ZERO)
data = construct_zone(
[ZoneTransition(datetime(2020, 1, 1), STD, STD)], "STD0"
).read()
zone_dir = tmp_path / "Fictional"
zone_dir.mkdir()
# Cut off inside the header
(zone_dir / "Truncated").write_bytes(data[:30])
with set_tzpath((str(tmp_path),), block_tzdata=True):
tz.gettz.cache_clear()
assert tz.gettz("Fictional/Truncated") is None
def _valid_tzif_data():
STD = ZoneOffset("STD", ZERO)
return construct_zone(
[ZoneTransition(datetime(2020, 1, 1), STD, STD)], "STD0"
).read()
def _unreadable_file(path):
path.write_bytes(_valid_tzif_data())
os.chmod(str(path), 0)
if os.access(str(path), os.R_OK): # pragma: nocover
# e.g. running as root
pytest.skip("Cannot make a file unreadable here")
INVALID_TZPATH_FILES = [
pytest.param(
lambda path: path.write_bytes(b"Not a TZif file\n"), id="not_tzif"
),
pytest.param(
lambda path: path.write_bytes(_valid_tzif_data()[:30]), id="truncated"
),
pytest.param(
_unreadable_file,
id="unreadable",
marks=pytest.mark.skipif(IS_WIN, reason="POSIX permissions"),
),
]
@pytest.mark.parametrize("make_invalid", INVALID_TZPATH_FILES)
def test_gettz_skips_invalid_file_on_tzpath(tmp_path, make_invalid):
"""An invalid file on TZPATH falls through to the next TZPATH entry."""
first = tmp_path / "first" / "Fictional"
second = tmp_path / "second" / "Fictional"
for directory in (first, second):
os.makedirs(str(directory))
make_invalid(first / "Zone")
(second / "Zone").write_bytes(_valid_tzif_data())
tzpath = (str(tmp_path / "first"), str(tmp_path / "second"))
with set_tzpath(tzpath, block_tzdata=True):
zone = tz.gettz("Fictional/Zone")
assert isinstance(zone, tz.tzfile)
# On Windows the key's "/" ends up in the path as it is
assert os.path.normpath(zone._filename) == str(second / "Zone")
@pytest.mark.parametrize("make_invalid", INVALID_TZPATH_FILES)
def test_gettz_invalid_file_on_tzpath_falls_back_to_tzdata(
tmp_path, make_invalid
):
"""An invalid file on TZPATH falls through to the tzdata package."""
pytest.importorskip("tzdata")
with set_tzpath((), block_tzdata=False):
expected = tz.gettz("America/New_York")
os.makedirs(str(tmp_path / "America"))
make_invalid(tmp_path / "America" / "New_York")
with set_tzpath((str(tmp_path),), block_tzdata=False):
zone = tz.gettz("America/New_York")
assert isinstance(zone, tz.tzfile)
assert zone == expected
assert zone._filename != str(tmp_path / "America" / "New_York")
@pytest.mark.skipif(IS_WIN, reason="The Windows registry has these names")
@pytest.mark.parametrize("name", ["UTC", "GMT"])
def test_gettz_utc_without_time_zone_data(name):
"""With no time zone data at all, "UTC" and "GMT" are still UTC."""
with set_tzpath((), block_tzdata=True):
assert tz.gettz(name) is tz.UTC
@pytest.mark.skipif(IS_WIN, reason="The Windows registry has these names")
def test_gettz_local_abbreviation_without_time_zone_data():
"""Abbreviations of the local zone that aren't keys give tzlocal()."""
names = [
name
for name in time_module.tzname
if name and not any(c.isdigit() for c in name)
]
if not names: # pragma: nocover
pytest.skip("The local zone has no alphabetic abbreviations")
with set_tzpath((), block_tzdata=True):
for name in names:
assert isinstance(tz.gettz(name), tz.tzlocal)
def test_missing_footer_newline():
"""Version 2+ files must have a newline before the TZ string."""
zf = construct_zone([], "EST5EDT,M3.2.0,M11.1.0")
data = zf.read()
# The footer is "\n<tzstr>\n"; drop the leading newline.
footer_start = data.rfind(b"\n", 0, len(data) - 1)
data = data[:footer_start] + data[footer_start + 1 :]
with pytest.raises(ValueError):
tz.tzfile(io.BytesIO(data))
def test_invalid_transition_index():
"""A transition index past the end of the ttinfo table is an error."""
STD = ZoneOffset("STD", ZERO)
DST = ZoneOffset("DST", ONE_H, ONE_H)
zf = construct_zone(
[
ZoneTransition(datetime(2026, 3, 1, 2), STD, DST),
ZoneTransition(datetime(2026, 11, 1, 2), DST, STD),
],
after="",
version=1,
)
data = bytearray(zf.read())
timecnt = struct.unpack_from(">l", data, 32)[0]
idx_offset = 44 + timecnt * 4
data[idx_offset + 1] = 2 # typecnt is 2, so index 2 is out of bounds
with pytest.raises(ValueError):
tz.tzfile(io.BytesIO(bytes(data)))
def test_transition_lookahead_out_of_bounds():
"""Inferring DST offsets must not look past the last transition."""
STD = ZoneOffset("STD", ZERO)
DST = ZoneOffset("DST", ONE_H, ONE_H)
EXT = ZoneOffset("EXT", ONE_H)
zf = construct_zone(
[
ZoneTransition(datetime(2026, 3, 1), STD, DST),
ZoneTransition(datetime(2026, 6, 1), DST, EXT),
ZoneTransition(datetime(2026, 9, 1), EXT, DST),
],
after="",
)
assert tz.tzfile(zf) is not None
EXTREME_TZSTRS = [
# Extreme offset hour
"AAA24",
"AAA+24",
"AAA-24",
"AAA24BBB,J60/2,J300/2",
"AAA+24BBB,J60/2,J300/2",
"AAA-24BBB,J60/2,J300/2",
"AAA4BBB24,J60/2,J300/2",
"AAA4BBB+24,J60/2,J300/2",
"AAA4BBB-24,J60/2,J300/2",
# Extreme offset minutes
"AAA4:00BBB,J60/2,J300/2",
"AAA4:59BBB,J60/2,J300/2",
"AAA4BBB5:00,J60/2,J300/2",
"AAA4BBB5:59,J60/2,J300/2",
# Extreme offset seconds
"AAA4:00:00BBB,J60/2,J300/2",
"AAA4:00:59BBB,J60/2,J300/2",
"AAA4BBB5:00:00,J60/2,J300/2",
"AAA4BBB5:00:59,J60/2,J300/2",
# Extreme total offset
"AAA24:59:59BBB5,J60/2,J300/2",
"AAA-24:59:59BBB5,J60/2,J300/2",
"AAA4BBB24:59:59,J60/2,J300/2",
"AAA4BBB-24:59:59,J60/2,J300/2",
# Extreme months
"AAA4BBB,M12.1.1/2,M1.1.1/2",
"AAA4BBB,M1.1.1/2,M12.1.1/2",
# Extreme weeks
"AAA4BBB,M1.5.1/2,M1.1.1/2",
"AAA4BBB,M1.1.1/2,M1.5.1/2",
# Extreme weekday
"AAA4BBB,M1.1.6/2,M2.1.1/2",
"AAA4BBB,M1.1.1/2,M2.1.6/2",
# Extreme numeric offset
"AAA4BBB,0/2,20/2",
"AAA4BBB,0/2,0/14",
"AAA4BBB,20/2,365/2",
"AAA4BBB,365/2,365/14",
# Extreme julian offset
"AAA4BBB,J1/2,J20/2",
"AAA4BBB,J1/2,J1/14",
"AAA4BBB,J20/2,J365/2",
"AAA4BBB,J365/2,J365/14",
# Leading-zero day-of-year
"AAA4BBB,J001/2,J065/2",
"AAA4BBB,001/2,065/2",
# Extreme transition hour
"AAA4BBB,J60/167,J300/2",
"AAA4BBB,J60/+167,J300/2",
"AAA4BBB,J60/-167,J300/2",
"AAA4BBB,J60/2,J300/167",
"AAA4BBB,J60/2,J300/+167",
"AAA4BBB,J60/2,J300/-167",
# Extreme transition minutes
"AAA4BBB,J60/2:00,J300/2",
"AAA4BBB,J60/2:59,J300/2",
"AAA4BBB,J60/2,J300/2:00",
"AAA4BBB,J60/2,J300/2:59",
# Extreme transition seconds
"AAA4BBB,J60/2:00:00,J300/2",
"AAA4BBB,J60/2:00:59,J300/2",
"AAA4BBB,J60/2,J300/2:00:00",
"AAA4BBB,J60/2,J300/2:00:59",
# Extreme total transition time
"AAA4BBB,J60/167:59:59,J300/2",
"AAA4BBB,J60/-167:59:59,J300/2",
"AAA4BBB,J60/2,J300/167:59:59",
"AAA4BBB,J60/2,J300/-167:59:59",
]
@pytest.mark.parametrize("tzstr", EXTREME_TZSTRS)
def test_extreme_tzstr(tzstr):
"""TZ strings at the edge of the valid range are accepted."""
assert zone_from_tzstr(tzstr) is not None
INVALID_TZSTRS = [
"PST8PDT", # DST but no transition specified
# The std offset is required (POSIX TZ grammar)
"AAA",
"A",
"AA",
"B",
"+11", # Unquoted alphanumeric
"GMT,M3.2.0/2,M11.1.0/3", # Transition rule but no DST
# Transition rules but no DST abbreviation
"EST5,M3.2.0,M11.1.0",
"<+04>-4,J60,J300",
"AAA3,0/0,J365/25",
"GMT0+11,M3.2.0/2,M11.1.0/3", # Unquoted alphanumeric in DST
# Unquoted abbreviation with embedded or leading whitespace
"AB C3",
" A B 3",
"AAA4BB B,J60/2,J300/2", # Embedded whitespace in DST
# Empty quoted abbreviation
"<>5",
"AAA4<>,M3.2.0/2,M11.1.0/3",
"PST8PDT,M3.2.0/2", # Only one transition rule
# Invalid offset hours
"AAA168",
"AAA+168",
"AAA-168",
"AAA168BBB,J60/2,J300/2",
"AAA+168BBB,J60/2,J300/2",
"AAA-168BBB,J60/2,J300/2",
"AAA4BBB168,J60/2,J300/2",
"AAA4BBB+168,J60/2,J300/2",
"AAA4BBB-168,J60/2,J300/2",
# Invalid offset minutes
"AAA4:0BBB,J60/2,J300/2",
"AAA4:100BBB,J60/2,J300/2",
"AAA4BBB5:0,J60/2,J300/2",
"AAA4BBB5:100,J60/2,J300/2",
# Invalid offset seconds
"AAA4:00:0BBB,J60/2,J300/2",
"AAA4:00:100BBB,J60/2,J300/2",
"AAA4BBB5:00:0,J60/2,J300/2",
"AAA4BBB5:00:100,J60/2,J300/2",
# Completely invalid dates
"AAA4BBB,M1443339,M11.1.0/3",
"AAA4BBB,M3.2.0/2,0349309483959c",
"AAA4BBB,,J300/2",
"AAA4BBB,z,J300/2",
"AAA4BBB,J60/2,",
"AAA4BBB,J60/2,z",
# Invalid months
"AAA4BBB,M13.1.1/2,M1.1.1/2",
"AAA4BBB,M1.1.1/2,M13.1.1/2",
"AAA4BBB,M0.1.1/2,M1.1.1/2",
"AAA4BBB,M1.1.1/2,M0.1.1/2",
# Invalid weeks
"AAA4BBB,M1.6.1/2,M1.1.1/2",
"AAA4BBB,M1.1.1/2,M1.6.1/2",
# Invalid weekday
"AAA4BBB,M1.1.7/2,M2.1.1/2",
"AAA4BBB,M1.1.1/2,M2.1.7/2",
# Invalid Mm.w.d separator
"AAA4BBB,M3.2X0,M11.1.0",
"AAA4BBB,M3.2.0,M11.1X0",
"AAA4BBB,M3.2-0,M11.1.0/3",
"AAA4BBB,M3.2.0/2,M11.1:0",
# Invalid numeric offset
"AAA4BBB,-1/2,20/2",
"AAA4BBB,1/2,-1/2",
"AAA4BBB,367,20/2",
"AAA4BBB,1/2,367/2",
# Invalid julian offset
"AAA4BBB,J0/2,J20/2",
"AAA4BBB,J20/2,J366/2",
# Non-digit day-of-year
"AAA4BBB,J1_0,J300/2",
"AAA4BBB,J60/2,J30_0/2",
"AAA4BBB,1_0,J300/2",
"AAA4BBB,J+1,J300/2",
"AAA4BBB,J 1,J300/2",
"AAA4BBB, 1,J300/2",
"AAA4BBB,J0001,J300/2",
"AAA4BBB,0001,J300/2",
# Invalid transition time
"AAA4BBB,J60/2/3,J300/2",
"AAA4BBB,J60/2,J300/2/3",
# Invalid transition hour
"AAA4BBB,J60/168,J300/2",
"AAA4BBB,J60/+168,J300/2",
"AAA4BBB,J60/-168,J300/2",
"AAA4BBB,J60/2,J300/168",
"AAA4BBB,J60/2,J300/+168",
"AAA4BBB,J60/2,J300/-168",
# Invalid transition minutes
"AAA4BBB,J60/2:0,J300/2",
"AAA4BBB,J60/2:100,J300/2",
"AAA4BBB,J60/2,J300/2:0",
"AAA4BBB,J60/2,J300/2:100",
# Invalid transition seconds
"AAA4BBB,J60/2:00:0,J300/2",
"AAA4BBB,J60/2:00:100,J300/2",
"AAA4BBB,J60/2,J300/2:00:0",
"AAA4BBB,J60/2,J300/2:00:100",
]
@pytest.mark.parametrize("tzstr", INVALID_TZSTRS)
def test_invalid_tzstr(tzstr):
"""Malformed TZ strings raise ValueError naming the string."""
import re
with pytest.raises(ValueError, match=re.escape(tzstr)):
zone_from_tzstr(tzstr)
@pytest.mark.parametrize(
"tzstr",
[
# Non-ASCII letter in the abbreviation
"AB" + chr(0xC0) + "C3",
# Non-ASCII digit in the day-of-year
"AAA4BBB,J" + chr(0x661) + ",J300/2",
],
)
def test_invalid_tzstr_non_ascii(tzstr):
with pytest.raises(ValueError):
zone_from_tzstr(tzstr, encoding="utf-8")
@pytest.mark.parametrize(
"tz_str, dt, expected_seconds",
[
("<-0044>0:44:30", datetime(2020, 1, 15), -2670),
("AAA0:44:30BBB,M3.2.0,M11.1.0", datetime(2020, 1, 15), -2670),
("AAA0:44:30BBB,M3.2.0,M11.1.0", datetime(2020, 7, 15), 930),
("AAA0:44:30BBB-0:44:30,M3.2.0,M11.1.0", datetime(2020, 7, 15), 2670),
],
)
def test_sub_minute_offset_tz_str(tz_str, dt, expected_seconds):
"""Sub-minute offsets in the TZ string are kept exact, like those in the file."""
GMT = ZoneOffset("GMT", ZERO)
zone = tz.tzfile(
construct_zone([ZoneTransition(datetime(1900, 1, 1), GMT, GMT)], tz_str)
)
expected = timedelta(seconds=expected_seconds)
assert dt.replace(tzinfo=zone).utcoffset() == expected
def test_tzfile_repr():
"""When the key is specified with a file object, it should be used in the repr."""
GMT = ZoneOffset("GMT", ZERO, ZERO)
BST = ZoneOffset("BST", ONE_H, ONE_H)
zf = construct_zone(
[
ZoneTransition(datetime(2005, 3, 27, 1), GMT, BST),
]
)
zi = tz.tzfile(zf, key="Europe/London")
assert "Europe/London" in repr(zi)
@pytest.mark.parametrize("zoneinfo_cache", ["fat"], indirect=True)
@pytest.mark.parametrize("source", ["tzdata", "tzpath"])
def test_gettz_space_for_underscore(source, zoneinfo_cache):
"""Spaces in a key are tried as underscores, whichever source is used."""
if source == "tzdata":
pytest.importorskip("tzdata")
paths = ()
else:
paths = (zoneinfo_cache[0],)
with set_tzpath(paths, block_tzdata=(source != "tzdata")):
tz.gettz.cache_clear()
with_space = tz.gettz("America/New York")
assert with_space is not None
assert with_space.key == "America/New York"
assert with_space == tz.gettz("America/New_York")
####
# Key handling in gettz
@pytest.mark.parametrize("zoneinfo_cache", ["fat"], indirect=True)
@pytest.mark.parametrize("source", ["tzdata", "tzpath"])
def test_gettz_key_attribute_by_source(source, zoneinfo_cache):
"""The key is the string passed to gettz whichever source supplied it."""
if source == "tzdata":
pytest.importorskip("tzdata")
paths = ()
else:
paths = (zoneinfo_cache[0],)
with set_tzpath(paths, block_tzdata=(source != "tzdata")):
tz.gettz.cache_clear()
for key in ("America/New_York", "Europe/London"):
zone = tz.gettz(key)
assert isinstance(zone, tz.tzfile)
assert zone.key == key
@pytest.mark.parametrize("zoneinfo_cache", ["fat"], indirect=True)
def test_gettz_absolute_path(zoneinfo_cache):
"""An absolute path to a TZif file is loaded directly and has no key."""
fpath = os.path.join(zoneinfo_cache[0], "America", "New_York")
with set_tzpath((), block_tzdata=True):
tz.gettz.cache_clear()
zone = tz.gettz(fpath)
assert isinstance(zone, tz.tzfile)
assert zone.key is None
assert repr(zone) == "tzfile(%r)" % fpath
@pytest.mark.parametrize("zoneinfo_cache", ["fat"], indirect=True)
def test_gettz_absolute_path_not_a_zone(zoneinfo_cache, tmp_path):
"""Absolute paths that are not TZif files return None."""
not_tzif = tmp_path / "not_a_zone"
not_tzif.write_bytes(b"This is not a TZif file")
with set_tzpath((), block_tzdata=True):
tz.gettz.cache_clear()
# A directory
assert tz.gettz(os.path.join(zoneinfo_cache[0], "America")) is None
# A file that does not exist
assert tz.gettz(os.path.join(zoneinfo_cache[0], "Nope")) is None
# A file that exists but is not a zone is an error, since an
# absolute path is an explicit request for that file.
with pytest.raises(ValueError):
tz.gettz(str(not_tzif))
@pytest.mark.parametrize("zoneinfo_cache", ["fat"], indirect=True)
@pytest.mark.parametrize(
"key",
[
"America/Nope",
"America", # A directory on the search path
"America/",
"America/New_York\x00",
"\x00",
],
)
def test_gettz_tzpath_bad_keys(zoneinfo_cache, key):
"""Keys that do not name a zone on the search path return None."""
with set_tzpath((zoneinfo_cache[0],), block_tzdata=True):
tz.gettz.cache_clear()
assert tz.gettz(key) is None
@pytest.mark.parametrize("zoneinfo_cache", ["fat"], indirect=True)
def test_gettz_missing_search_path_entry(zoneinfo_cache, tmp_path):
"""Search path entries that do not exist are skipped, not errors."""
missing = str(tmp_path / "does_not_exist")
with set_tzpath((missing, zoneinfo_cache[0]), block_tzdata=True):
tz.gettz.cache_clear()
assert tz.gettz("America/New_York") is not None
assert tz.gettz("America/Nope") is None
@pytest.mark.parametrize(
"key",
[
"America", # A directory
"America/",
"Fictional/Zone",
"Fictional",
"America/New_York\x00", # Not a valid path
"America/../UTC", # Not a valid package name
# Files in the tzdata package that are not TZif files
"__init__.py",
"America/__init__.py",
"zone.tab",
"tzdata.zi",
"leapseconds",
# Not encodable as a module or file name on some versions
"Am" + chr(0xE9) + "rica/New_York",
"America/" + chr(0xD800),
chr(0xD800),
],
)
def test_tzdata_bad_keys(key):
"""Keys that do not name a zone in tzdata return None, not an error."""
pytest.importorskip("tzdata")
with set_tzpath(()):
tz.gettz.cache_clear()
assert tz.gettz(key) is None
####
# available_iana_timezones
def _make_fake_tzpath(root):
"""Populate a directory the way a zoneinfo installation is laid out."""
STD = ZoneOffset("STD", ZERO)
DST = ZoneOffset("DST", ONE_H, ONE_H)
tzif = construct_zone(
[ZoneTransition(datetime(2010, 3, 14, 2), STD, DST)],
"STD0DST,M3.2.0,M11.1.0",
).read()
zones = [
"Fictional/Liliput",
"Fictional/Blefuscu",
"Fictional/Nested/Deep",
"Fictional/right/NotExcluded", # Only top-level right/ is excluded
"UTC",
"posixrules", # Excluded by name
"localtime", # Excluded by name
"posix/Fictional/Liliput", # Excluded top-level directory
"right/Fictional/Liliput", # Excluded top-level directory
]
for zone in zones:
path = os.path.join(root, *zone.split("/"))
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(tzif)
for not_zone in ["tzdata.zi", "Fictional/README", "leap-seconds.list"]:
path = os.path.join(root, *not_zone.split("/"))
with open(path, "wb") as f:
f.write(b"This is not a TZif file\n")
return {
"Fictional/Liliput",
"Fictional/Blefuscu",
"Fictional/Nested/Deep",
"Fictional/right/NotExcluded",
"UTC",
}
def test_available_iana_timezones_tzpath_only(tmp_path):
root = str(tmp_path)
expected = _make_fake_tzpath(root)
with set_tzpath((root,), block_tzdata=True):
assert tz.available_iana_timezones() == expected
def test_available_iana_timezones_missing_path(tmp_path):
"""Search path entries that do not exist are skipped."""
root = str(tmp_path / "zoneinfo")
os.makedirs(root)
expected = _make_fake_tzpath(root)
missing = str(tmp_path / "missing")
with set_tzpath((missing, root), block_tzdata=True):
assert tz.available_iana_timezones() == expected
def test_available_iana_timezones_empty(tmp_path):
with set_tzpath((), block_tzdata=True):
assert tz.available_iana_timezones() == set()
def test_available_iana_timezones_with_tzdata(tmp_path):
"""Zones on the search path are merged with the ones from tzdata."""
tzdata = pytest.importorskip("tzdata")
from dateutil._tzdata_impl import _load_tzdata_keys
root = str(tmp_path)
fake_zones = _make_fake_tzpath(root)
tzdata_zones = set(_load_tzdata_keys())
with set_tzpath((root,)):
zones = tz.available_iana_timezones()
assert zones == fake_zones | tzdata_zones
assert "posixrules" not in zones
def test_available_iana_timezones_fresh_set():
"""Each call returns a new set, so callers may mutate it."""
with set_tzpath((), block_tzdata=True):
a = tz.available_iana_timezones()
b = tz.available_iana_timezones()
assert a is not b
####
# TZPATH and reset_tzpath
def test_tz_tzpath_tracks_reset(tmp_path):
"""dateutil.tz.TZPATH reflects reset_tzpath on every Python version."""
new_path = str(tmp_path)
with set_tzpath((new_path,)):
assert tz.TZPATH == (new_path,)
assert "TZPATH" in dir(tz)
assert tz.TZPATH != (new_path,)
def test_invalid_tzpath_warning_is_zoneinfos():
import zoneinfo
assert tz._tzpath.InvalidTZPathWarning is zoneinfo.InvalidTZPathWarning
####
# Sub-minute offsets
def _sub_minute_zone():
# Africa/Abidjan's LMT offset is -0:16:08; the file must contain the
# exact value.
LMT = ZoneOffset("LMT", timedelta(seconds=-968))
GMT = ZoneOffset("GMT", ZERO)
return construct_zone(
[ZoneTransition(datetime(1912, 1, 1), LMT, GMT)], "GMT0"
)
def test_sub_minute_offset():
"""Sub-minute offsets retain their exact value."""
zone = tz.tzfile(_sub_minute_zone())
dt = datetime(1900, 1, 1, tzinfo=zone)
assert dt.utcoffset() == timedelta(seconds=-968)
assert dt.tzname() == "LMT"
assert datetime(2000, 1, 1, tzinfo=zone).utcoffset() == ZERO
def test_sub_minute_offset_round_trip():
"""Converting to and from UTC near a sub-minute transition round trips."""
# America/New_York's LMT offset is -4:56:02, so the switch to EST in 1883
# creates a fold whose boundaries move when the offset is rounded.
LMT = ZoneOffset("LMT", timedelta(seconds=-17762))
EST = ZoneOffset("EST", timedelta(hours=-5))
zone = tz.tzfile(
construct_zone(
[ZoneTransition(datetime(1883, 11, 18, 12, 3, 58), LMT, EST)],
"EST5",
)
)
transition = datetime(1883, 11, 18, 17, tzinfo=tz.UTC)
for seconds in range(-120, 121):
dt_utc = transition + timedelta(seconds=seconds)
assert dt_utc.astimezone(zone).astimezone(tz.UTC) == dt_utc
@pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1))
def test_sub_minute_offset_pickle(protocol):
"""Pickling preserves sub-minute offsets."""
zone = tz.tzfile(_sub_minute_zone(), key="Africa/Abidjan")
data = pickle.dumps(zone, protocol=protocol)
# The pickled data carries the unrounded offsets from the file, so a
# pickle made on a version that rounds still loads exactly elsewhere.
_, args = zone.__reduce_ex__(protocol)
_, _, _, zone_data = args
utcoff = zone_data[2]
assert -968 in utcoff
unpickled = pickle.loads(data)
assert unpickled == zone
assert unpickled.key == "Africa/Abidjan"
assert (
datetime(1900, 1, 1, tzinfo=unpickled).utcoffset()
== datetime(1900, 1, 1, tzinfo=zone).utcoffset()
)
class _DescribedTzfile(tz.tzfile):
def __init__(self, fileobj, description, **kwargs):
super().__init__(fileobj, **kwargs)
self.description = description
@pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1))
def test_pickle_subclass_attributes(protocol):
"""Pickling preserves attributes added by subclasses."""
STD = ZoneOffset("STD", -5 * ONE_H)
DST = ZoneOffset("DST", -4 * ONE_H, ONE_H)
transitions = [
ZoneTransition(datetime(2020, 3, 8, 2), STD, DST),
ZoneTransition(datetime(2020, 11, 1, 2), DST, STD),
]
zone = _DescribedTzfile(
construct_zone(transitions, "STD5DST,M3.2.0,M11.1.0"),
"Somewhere in the eastern US",
key="Fictional/Eastern",
)
unpickled = pickle.loads(pickle.dumps(zone, protocol=protocol))
assert type(unpickled) is _DescribedTzfile
assert unpickled == zone
assert unpickled.key == "Fictional/Eastern"
assert unpickled.description == "Somewhere in the eastern US"
assert (
datetime(2020, 7, 1, tzinfo=unpickled).utcoffset()
== datetime(2020, 7, 1, tzinfo=zone).utcoffset()
)
@pytest.mark.parametrize("key", ["America/New_York", "UTC", None])
def test_pickle_no_extra_state(key):
"""A plain tzfile is pickled entirely from its data.
If this fails, an attribute set when loading a tzfile is missing from
tzfile._UNPICKLE_ATTRS, so it is pickled twice.
"""
if key is None:
zone = tz.tzfile(_sub_minute_zone())
else:
zone = tz.gettz(key)
assert isinstance(zone, tz.tzfile)
assert len(zone.__reduce_ex__(pickle.HIGHEST_PROTOCOL)) == 2
####
# dst() inference
#
# TZif files only say whether each local time type is DST, so the amounts of
# DST in these ZoneOffsets don't make it into the files; the expected dst()
# values are given separately.
@as_list
def _dst_inference_cases():
def hm(hours, minutes=0, seconds=0):
sign = -1 if hours < 0 else 1
return sign * timedelta(
hours=abs(hours), minutes=minutes, seconds=seconds
)
# The same local time type used for double summer time in one era and
# for summer time in another, as with UTC+0 DST in Atlantic/Azores.
STD_2 = ZoneOffset("-02", hm(-2))
SUMMER = ZoneOffset("-01", hm(-1), ONE_H)
DOUBLE = ZoneOffset("+00", ZERO, 2 * ONE_H)
STD_1 = ZoneOffset("-01", hm(-1))
yield pytest.param(
[
ZoneTransition(datetime(1942, 4, 25), STD_2, SUMMER),
ZoneTransition(datetime(1942, 8, 15), SUMMER, DOUBLE),
ZoneTransition(datetime(1942, 9, 1), DOUBLE, SUMMER),
ZoneTransition(datetime(1942, 10, 25), SUMMER, STD_2),
ZoneTransition(datetime(1983, 9, 25), STD_2, STD_1),
ZoneTransition(datetime(1984, 3, 25), STD_1, DOUBLE),
ZoneTransition(datetime(1984, 9, 30), DOUBLE, STD_1),
],
[
(datetime(1942, 7, 1), ONE_H),
(datetime(1942, 8, 20), 2 * ONE_H),
(datetime(1984, 7, 1), ONE_H),
],
id="local_time_type_reused",
)
# The standard offset changes when DST starts, as in
# America/Bahia_Banderas in 2010.
MST = ZoneOffset("MST", hm(-7))
MDT = ZoneOffset("MDT", hm(-6), ONE_H)
CST = ZoneOffset("CST", hm(-6))
CDT = ZoneOffset("CDT", hm(-5), ONE_H)
yield pytest.param(
[
ZoneTransition(datetime(2009, 4, 5, 2), MST, MDT),
ZoneTransition(datetime(2009, 10, 25, 2), MDT, MST),
ZoneTransition(datetime(2010, 4, 4, 2), MST, CDT),
ZoneTransition(datetime(2010, 10, 31, 2), CDT, CST),
ZoneTransition(datetime(2011, 4, 3, 2), CST, CDT),
ZoneTransition(datetime(2011, 10, 30, 2), CDT, CST),
],
[
(datetime(2009, 7, 1), ONE_H),
(datetime(2010, 7, 1), ONE_H),
(datetime(2011, 7, 1), ONE_H),
],
id="standard_offset_changes_with_dst",
)
# The standard offset changes when DST ends, so the amounts implied by
# the standard time before and after differ. Only the DST used nearby
# tells Asia/Tehran in 1977 (1 hour) and Pacific/Rarotonga in 1978 (30
# minutes) apart.
STD_A = ZoneOffset("STDA", hm(3, 30))
DST_A = ZoneOffset("DSTA", hm(4, 30), ONE_H)
STD_B = ZoneOffset("STDB", hm(4))
DST_B = ZoneOffset("DSTB", hm(5), ONE_H)
yield pytest.param(
[
ZoneTransition(datetime(1976, 3, 21), STD_A, DST_A),
ZoneTransition(datetime(1976, 10, 21), DST_A, STD_A),
ZoneTransition(datetime(1977, 3, 21), STD_A, DST_A),
ZoneTransition(datetime(1977, 10, 21), DST_A, STD_B),
ZoneTransition(datetime(1978, 3, 21), STD_B, DST_B),
ZoneTransition(datetime(1978, 10, 21), DST_B, STD_B),
],
[(datetime(1977, 7, 1), ONE_H)],
id="nearby_dst_one_hour",
)
STD_A = ZoneOffset("-1030", hm(-10, 30))
DST_A = ZoneOffset("-0930", hm(-9, 30), timedelta(minutes=30))
STD_B = ZoneOffset("-10", hm(-10))
yield pytest.param(
[
ZoneTransition(datetime(1978, 11, 12), STD_A, DST_A),
ZoneTransition(datetime(1979, 3, 4), DST_A, STD_B),
ZoneTransition(datetime(1979, 10, 28), STD_B, DST_A),
ZoneTransition(datetime(1980, 3, 2), DST_A, STD_B),
],
[
(datetime(1979, 1, 1), timedelta(minutes=30)),
(datetime(1980, 1, 1), timedelta(minutes=30)),
],
id="nearby_dst_half_hour",
)
# When nothing else decides, prefer positive DST: in the 1942 occupation
# zones, e.g. Europe/Kyiv, the standard time before gives -1:00 and the
# one after gives +1:00.
MSK = ZoneOffset("MSK", hm(3))
CEST = ZoneOffset("CEST", hm(2), ONE_H)
CET = ZoneOffset("CET", hm(1))
yield pytest.param(
[
ZoneTransition(datetime(1941, 9, 20), MSK, CEST),
ZoneTransition(datetime(1942, 11, 2, 3), CEST, CET),
],
[(datetime(1942, 7, 1), ONE_H)],
id="prefer_positive",
)
# A change from a mean time when DST ends, as in America/La_Paz in
# 1932, implies an implausible amount of DST (0:27:24 here).
CMT = ZoneOffset("CMT", hm(-4, 32, 36))
BST = ZoneOffset("BST", hm(-3, 32, 36), ONE_H)
BOT = ZoneOffset("-04", hm(-4))
yield pytest.param(
[
ZoneTransition(datetime(1931, 10, 15), CMT, BST),
ZoneTransition(datetime(1932, 3, 21), BST, BOT),
],
[(datetime(1932, 1, 1), ONE_H)],
id="implausible_dst",
)
@pytest.mark.parametrize("transitions, expected", _dst_inference_cases())
def test_dst_inference(transitions, expected):
"""dst() is inferred from the standard time around each DST period."""
zone = tz.tzfile(construct_zone(transitions))
for dt_utc, dst in expected:
dt = dt_utc.replace(tzinfo=tz.UTC).astimezone(zone)
assert dt.dst() == dst, dt_utc
assert dt.replace(tzinfo=None).replace(tzinfo=zone).dst() == dst
# The pickled data is the data from the file, so the inference gives the
# same answers after a round trip
assert pickle.loads(pickle.dumps(zone)) == zone
####
# Thread safety
def test_gettz_thread_safety():
"""Concurrent gettz calls, with the cache being cleared underneath them."""
keys = ["America/New_York", "Europe/London", "Asia/Tokyo", "UTC"]
expected = {key: tz.gettz(key) for key in keys}
assert all(isinstance(v, tz.tzfile) for v in expected.values())
errors = []
n_iterations = 200
def worker(key):
try:
for i in range(n_iterations):
zone = tz.gettz(key)
if zone != expected[key]:
raise AssertionError(
"%s: %r != %r" % (key, zone, expected[key])
)
except Exception as e: # pragma: nocover
errors.append(e)
def clearer():
try:
for i in range(n_iterations // 4):
tz.gettz.cache_clear()
except Exception as e: # pragma: nocover
errors.append(e)
threads = [threading.Thread(target=worker, args=(key,)) for key in keys * 2]
threads.append(threading.Thread(target=clearer))
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors