Closes issue #24773: Implement PEP 495 (Local Time Disambiguation).

This commit is contained in:
Alexander Belopolsky 2016-07-22 18:47:04 -04:00
parent 638e622055
commit 5d0c598382
7 changed files with 1601 additions and 227 deletions

View file

@ -2,14 +2,22 @@
See http://www.zope.org/Members/fdrake/DateTimeWiki/TestCases
"""
from test.support import requires
import itertools
import bisect
import copy
import decimal
import sys
import os
import pickle
import random
import struct
import unittest
from array import array
from operator import lt, le, gt, ge, eq, ne, truediv, floordiv, mod
from test import support
@ -1592,6 +1600,10 @@ class TestDateTime(TestDate):
self.assertEqual(t.isoformat(' '), "0002-03-02 00:00:00")
# str is ISO format with the separator forced to a blank.
self.assertEqual(str(t), "0002-03-02 00:00:00")
# ISO format with timezone
tz = FixedOffset(timedelta(seconds=16), 'XXX')
t = self.theclass(2, 3, 2, tzinfo=tz)
self.assertEqual(t.isoformat(), "0002-03-02T00:00:00+00:00:16")
def test_format(self):
dt = self.theclass(2007, 9, 10, 4, 5, 1, 123)
@ -1711,6 +1723,9 @@ class TestDateTime(TestDate):
self.assertRaises(ValueError, self.theclass,
2000, 1, 31, 23, 59, 59,
1000000)
# Positional fold:
self.assertRaises(TypeError, self.theclass,
2000, 1, 31, 23, 59, 59, 0, None, 1)
def test_hash_equality(self):
d = self.theclass(2000, 12, 31, 23, 30, 17)
@ -1894,16 +1909,20 @@ class TestDateTime(TestDate):
t = self.theclass(1970, 1, 1, 1, 2, 3, 4)
self.assertEqual(t.timestamp(),
18000.0 + 3600 + 2*60 + 3 + 4*1e-6)
# Missing hour may produce platform-dependent result
t = self.theclass(2012, 3, 11, 2, 30)
self.assertIn(self.theclass.fromtimestamp(t.timestamp()),
[t - timedelta(hours=1), t + timedelta(hours=1)])
# Missing hour
t0 = self.theclass(2012, 3, 11, 2, 30)
t1 = t0.replace(fold=1)
self.assertEqual(self.theclass.fromtimestamp(t1.timestamp()),
t0 - timedelta(hours=1))
self.assertEqual(self.theclass.fromtimestamp(t0.timestamp()),
t1 + timedelta(hours=1))
# Ambiguous hour defaults to DST
t = self.theclass(2012, 11, 4, 1, 30)
self.assertEqual(self.theclass.fromtimestamp(t.timestamp()), t)
# Timestamp may raise an overflow error on some platforms
for t in [self.theclass(1,1,1), self.theclass(9999,12,12)]:
# XXX: Do we care to support the first and last year?
for t in [self.theclass(2,1,1), self.theclass(9998,12,12)]:
try:
s = t.timestamp()
except OverflowError:
@ -1922,6 +1941,7 @@ class TestDateTime(TestDate):
self.assertEqual(t.timestamp(),
18000 + 3600 + 2*60 + 3 + 4*1e-6)
@support.run_with_tz('MSK-03') # Something east of Greenwich
def test_microsecond_rounding(self):
for fts in [self.theclass.fromtimestamp,
self.theclass.utcfromtimestamp]:
@ -2127,6 +2147,7 @@ class TestDateTime(TestDate):
self.assertRaises(ValueError, base.replace, year=2001)
def test_astimezone(self):
return # The rest is no longer applicable
# Pretty boring! The TZ test is more interesting here. astimezone()
# simply can't be applied to a naive object.
dt = self.theclass.now()
@ -2619,9 +2640,9 @@ class TZInfoBase:
self.assertRaises(ValueError, t.utcoffset)
self.assertRaises(ValueError, t.dst)
# Not a whole number of minutes.
# Not a whole number of seconds.
class C7(tzinfo):
def utcoffset(self, dt): return timedelta(seconds=61)
def utcoffset(self, dt): return timedelta(microseconds=61)
def dst(self, dt): return timedelta(microseconds=-81)
t = cls(1, 1, 1, tzinfo=C7())
self.assertRaises(ValueError, t.utcoffset)
@ -3994,5 +4015,777 @@ class Oddballs(unittest.TestCase):
with self.assertRaises(TypeError):
datetime(10, 10, 10, 10, 10, 10, 10.)
#############################################################################
# Local Time Disambiguation
# An experimental reimplementation of fromutc that respects the "fold" flag.
class tzinfo2(tzinfo):
def fromutc(self, dt):
"datetime in UTC -> datetime in local time."
if not isinstance(dt, datetime):
raise TypeError("fromutc() requires a datetime argument")
if dt.tzinfo is not self:
raise ValueError("dt.tzinfo is not self")
# Returned value satisfies
# dt + ldt.utcoffset() = ldt
off0 = dt.replace(fold=0).utcoffset()
off1 = dt.replace(fold=1).utcoffset()
if off0 is None or off1 is None or dt.dst() is None:
raise ValueError
if off0 == off1:
ldt = dt + off0
off1 = ldt.utcoffset()
if off0 == off1:
return ldt
# Now, we discovered both possible offsets, so
# we can just try four possible solutions:
for off in [off0, off1]:
ldt = dt + off
if ldt.utcoffset() == off:
return ldt
ldt = ldt.replace(fold=1)
if ldt.utcoffset() == off:
return ldt
raise ValueError("No suitable local time found")
# Reimplementing simplified US timezones to respect the "fold" flag:
class USTimeZone2(tzinfo2):
def __init__(self, hours, reprname, stdname, dstname):
self.stdoffset = timedelta(hours=hours)
self.reprname = reprname
self.stdname = stdname
self.dstname = dstname
def __repr__(self):
return self.reprname
def tzname(self, dt):
if self.dst(dt):
return self.dstname
else:
return self.stdname
def utcoffset(self, dt):
return self.stdoffset + self.dst(dt)
def dst(self, dt):
if dt is None or dt.tzinfo is None:
# An exception instead may be sensible here, in one or more of
# the cases.
return ZERO
assert dt.tzinfo is self
# Find first Sunday in April.
start = first_sunday_on_or_after(DSTSTART.replace(year=dt.year))
assert start.weekday() == 6 and start.month == 4 and start.day <= 7
# Find last Sunday in October.
end = first_sunday_on_or_after(DSTEND.replace(year=dt.year))
assert end.weekday() == 6 and end.month == 10 and end.day >= 25
# Can't compare naive to aware objects, so strip the timezone from
# dt first.
dt = dt.replace(tzinfo=None)
if start + HOUR <= dt < end:
# DST is in effect.
return HOUR
elif end <= dt < end + HOUR:
# Fold (an ambiguous hour): use dt.fold to disambiguate.
return ZERO if dt.fold else HOUR
elif start <= dt < start + HOUR:
# Gap (a non-existent hour): reverse the fold rule.
return HOUR if dt.fold else ZERO
else:
# DST is off.
return ZERO
Eastern2 = USTimeZone2(-5, "Eastern2", "EST", "EDT")
Central2 = USTimeZone2(-6, "Central2", "CST", "CDT")
Mountain2 = USTimeZone2(-7, "Mountain2", "MST", "MDT")
Pacific2 = USTimeZone2(-8, "Pacific2", "PST", "PDT")
# Europe_Vilnius_1941 tzinfo implementation reproduces the following
# 1941 transition from Olson's tzdist:
#
# Zone NAME GMTOFF RULES FORMAT [UNTIL]
# ZoneEurope/Vilnius 1:00 - CET 1940 Aug 3
# 3:00 - MSK 1941 Jun 24
# 1:00 C-Eur CE%sT 1944 Aug
#
# $ zdump -v Europe/Vilnius | grep 1941
# Europe/Vilnius Mon Jun 23 20:59:59 1941 UTC = Mon Jun 23 23:59:59 1941 MSK isdst=0 gmtoff=10800
# Europe/Vilnius Mon Jun 23 21:00:00 1941 UTC = Mon Jun 23 23:00:00 1941 CEST isdst=1 gmtoff=7200
class Europe_Vilnius_1941(tzinfo):
def _utc_fold(self):
return [datetime(1941, 6, 23, 21, tzinfo=self), # Mon Jun 23 21:00:00 1941 UTC
datetime(1941, 6, 23, 22, tzinfo=self)] # Mon Jun 23 22:00:00 1941 UTC
def _loc_fold(self):
return [datetime(1941, 6, 23, 23, tzinfo=self), # Mon Jun 23 23:00:00 1941 MSK / CEST
datetime(1941, 6, 24, 0, tzinfo=self)] # Mon Jun 24 00:00:00 1941 CEST
def utcoffset(self, dt):
fold_start, fold_stop = self._loc_fold()
if dt < fold_start:
return 3 * HOUR
if dt < fold_stop:
return (2 if dt.fold else 3) * HOUR
# if dt >= fold_stop
return 2 * HOUR
def dst(self, dt):
fold_start, fold_stop = self._loc_fold()
if dt < fold_start:
return 0 * HOUR
if dt < fold_stop:
return (1 if dt.fold else 0) * HOUR
# if dt >= fold_stop
return 1 * HOUR
def tzname(self, dt):
fold_start, fold_stop = self._loc_fold()
if dt < fold_start:
return 'MSK'
if dt < fold_stop:
return ('MSK', 'CEST')[dt.fold]
# if dt >= fold_stop
return 'CEST'
def fromutc(self, dt):
assert dt.fold == 0
assert dt.tzinfo is self
if dt.year != 1941:
raise NotImplementedError
fold_start, fold_stop = self._utc_fold()
if dt < fold_start:
return dt + 3 * HOUR
if dt < fold_stop:
return (dt + 2 * HOUR).replace(fold=1)
# if dt >= fold_stop
return dt + 2 * HOUR
class TestLocalTimeDisambiguation(unittest.TestCase):
def test_vilnius_1941_fromutc(self):
Vilnius = Europe_Vilnius_1941()
gdt = datetime(1941, 6, 23, 20, 59, 59, tzinfo=timezone.utc)
ldt = gdt.astimezone(Vilnius)
self.assertEqual(ldt.strftime("%c %Z%z"),
'Mon Jun 23 23:59:59 1941 MSK+0300')
self.assertEqual(ldt.fold, 0)
self.assertFalse(ldt.dst())
gdt = datetime(1941, 6, 23, 21, tzinfo=timezone.utc)
ldt = gdt.astimezone(Vilnius)
self.assertEqual(ldt.strftime("%c %Z%z"),
'Mon Jun 23 23:00:00 1941 CEST+0200')
self.assertEqual(ldt.fold, 1)
self.assertTrue(ldt.dst())
gdt = datetime(1941, 6, 23, 22, tzinfo=timezone.utc)
ldt = gdt.astimezone(Vilnius)
self.assertEqual(ldt.strftime("%c %Z%z"),
'Tue Jun 24 00:00:00 1941 CEST+0200')
self.assertEqual(ldt.fold, 0)
self.assertTrue(ldt.dst())
def test_vilnius_1941_toutc(self):
Vilnius = Europe_Vilnius_1941()
ldt = datetime(1941, 6, 23, 22, 59, 59, tzinfo=Vilnius)
gdt = ldt.astimezone(timezone.utc)
self.assertEqual(gdt.strftime("%c %Z"),
'Mon Jun 23 19:59:59 1941 UTC')
ldt = datetime(1941, 6, 23, 23, 59, 59, tzinfo=Vilnius)
gdt = ldt.astimezone(timezone.utc)
self.assertEqual(gdt.strftime("%c %Z"),
'Mon Jun 23 20:59:59 1941 UTC')
ldt = datetime(1941, 6, 23, 23, 59, 59, tzinfo=Vilnius, fold=1)
gdt = ldt.astimezone(timezone.utc)
self.assertEqual(gdt.strftime("%c %Z"),
'Mon Jun 23 21:59:59 1941 UTC')
ldt = datetime(1941, 6, 24, 0, tzinfo=Vilnius)
gdt = ldt.astimezone(timezone.utc)
self.assertEqual(gdt.strftime("%c %Z"),
'Mon Jun 23 22:00:00 1941 UTC')
def test_constructors(self):
t = time(0, fold=1)
dt = datetime(1, 1, 1, fold=1)
self.assertEqual(t.fold, 1)
self.assertEqual(dt.fold, 1)
with self.assertRaises(TypeError):
time(0, 0, 0, 0, None, 0)
def test_member(self):
dt = datetime(1, 1, 1, fold=1)
t = dt.time()
self.assertEqual(t.fold, 1)
t = dt.timetz()
self.assertEqual(t.fold, 1)
def test_replace(self):
t = time(0)
dt = datetime(1, 1, 1)
self.assertEqual(t.replace(fold=1).fold, 1)
self.assertEqual(dt.replace(fold=1).fold, 1)
self.assertEqual(t.replace(fold=0).fold, 0)
self.assertEqual(dt.replace(fold=0).fold, 0)
# Check that replacement of other fields does not change "fold".
t = t.replace(fold=1, tzinfo=Eastern)
dt = dt.replace(fold=1, tzinfo=Eastern)
self.assertEqual(t.replace(tzinfo=None).fold, 1)
self.assertEqual(dt.replace(tzinfo=None).fold, 1)
# Check that fold is a keyword-only argument
with self.assertRaises(TypeError):
t.replace(1, 1, 1, None, 1)
with self.assertRaises(TypeError):
dt.replace(1, 1, 1, 1, 1, 1, 1, None, 1)
def test_comparison(self):
t = time(0)
dt = datetime(1, 1, 1)
self.assertEqual(t, t.replace(fold=1))
self.assertEqual(dt, dt.replace(fold=1))
def test_hash(self):
t = time(0)
dt = datetime(1, 1, 1)
self.assertEqual(hash(t), hash(t.replace(fold=1)))
self.assertEqual(hash(dt), hash(dt.replace(fold=1)))
@support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0')
def test_fromtimestamp(self):
s = 1414906200
dt0 = datetime.fromtimestamp(s)
dt1 = datetime.fromtimestamp(s + 3600)
self.assertEqual(dt0.fold, 0)
self.assertEqual(dt1.fold, 1)
@support.run_with_tz('Australia/Lord_Howe')
def test_fromtimestamp_lord_howe(self):
tm = _time.localtime(1.4e9)
if _time.strftime('%Z%z', tm) != 'LHST+1030':
self.skipTest('Australia/Lord_Howe timezone is not supported on this platform')
# $ TZ=Australia/Lord_Howe date -r 1428158700
# Sun Apr 5 01:45:00 LHDT 2015
# $ TZ=Australia/Lord_Howe date -r 1428160500
# Sun Apr 5 01:45:00 LHST 2015
s = 1428158700
t0 = datetime.fromtimestamp(s)
t1 = datetime.fromtimestamp(s + 1800)
self.assertEqual(t0, t1)
self.assertEqual(t0.fold, 0)
self.assertEqual(t1.fold, 1)
@support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0')
def test_timestamp(self):
dt0 = datetime(2014, 11, 2, 1, 30)
dt1 = dt0.replace(fold=1)
self.assertEqual(dt0.timestamp() + 3600,
dt1.timestamp())
@support.run_with_tz('Australia/Lord_Howe')
def test_timestamp_lord_howe(self):
tm = _time.localtime(1.4e9)
if _time.strftime('%Z%z', tm) != 'LHST+1030':
self.skipTest('Australia/Lord_Howe timezone is not supported on this platform')
t = datetime(2015, 4, 5, 1, 45)
s0 = t.replace(fold=0).timestamp()
s1 = t.replace(fold=1).timestamp()
self.assertEqual(s0 + 1800, s1)
@support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0')
def test_astimezone(self):
dt0 = datetime(2014, 11, 2, 1, 30)
dt1 = dt0.replace(fold=1)
# Convert both naive instances to aware.
adt0 = dt0.astimezone()
adt1 = dt1.astimezone()
# Check that the first instance in DST zone and the second in STD
self.assertEqual(adt0.tzname(), 'EDT')
self.assertEqual(adt1.tzname(), 'EST')
self.assertEqual(adt0 + HOUR, adt1)
# Aware instances with fixed offset tzinfo's always have fold=0
self.assertEqual(adt0.fold, 0)
self.assertEqual(adt1.fold, 0)
def test_pickle_fold(self):
t = time(fold=1)
dt = datetime(1, 1, 1, fold=1)
for pickler, unpickler, proto in pickle_choices:
for x in [t, dt]:
s = pickler.dumps(x, proto)
y = unpickler.loads(s)
self.assertEqual(x, y)
self.assertEqual((0 if proto < 4 else x.fold), y.fold)
def test_repr(self):
t = time(fold=1)
dt = datetime(1, 1, 1, fold=1)
self.assertEqual(repr(t), 'datetime.time(0, 0, fold=1)')
self.assertEqual(repr(dt),
'datetime.datetime(1, 1, 1, 0, 0, fold=1)')
def test_dst(self):
# Let's first establish that things work in regular times.
dt_summer = datetime(2002, 10, 27, 1, tzinfo=Eastern2) - timedelta.resolution
dt_winter = datetime(2002, 10, 27, 2, tzinfo=Eastern2)
self.assertEqual(dt_summer.dst(), HOUR)
self.assertEqual(dt_winter.dst(), ZERO)
# The disambiguation flag is ignored
self.assertEqual(dt_summer.replace(fold=1).dst(), HOUR)
self.assertEqual(dt_winter.replace(fold=1).dst(), ZERO)
# Pick local time in the fold.
for minute in [0, 30, 59]:
dt = datetime(2002, 10, 27, 1, minute, tzinfo=Eastern2)
# With fold=0 (the default) it is in DST.
self.assertEqual(dt.dst(), HOUR)
# With fold=1 it is in STD.
self.assertEqual(dt.replace(fold=1).dst(), ZERO)
# Pick local time in the gap.
for minute in [0, 30, 59]:
dt = datetime(2002, 4, 7, 2, minute, tzinfo=Eastern2)
# With fold=0 (the default) it is in STD.
self.assertEqual(dt.dst(), ZERO)
# With fold=1 it is in DST.
self.assertEqual(dt.replace(fold=1).dst(), HOUR)
def test_utcoffset(self):
# Let's first establish that things work in regular times.
dt_summer = datetime(2002, 10, 27, 1, tzinfo=Eastern2) - timedelta.resolution
dt_winter = datetime(2002, 10, 27, 2, tzinfo=Eastern2)
self.assertEqual(dt_summer.utcoffset(), -4 * HOUR)
self.assertEqual(dt_winter.utcoffset(), -5 * HOUR)
# The disambiguation flag is ignored
self.assertEqual(dt_summer.replace(fold=1).utcoffset(), -4 * HOUR)
self.assertEqual(dt_winter.replace(fold=1).utcoffset(), -5 * HOUR)
def test_fromutc(self):
# Let's first establish that things work in regular times.
u_summer = datetime(2002, 10, 27, 6, tzinfo=Eastern2) - timedelta.resolution
u_winter = datetime(2002, 10, 27, 7, tzinfo=Eastern2)
t_summer = Eastern2.fromutc(u_summer)
t_winter = Eastern2.fromutc(u_winter)
self.assertEqual(t_summer, u_summer - 4 * HOUR)
self.assertEqual(t_winter, u_winter - 5 * HOUR)
self.assertEqual(t_summer.fold, 0)
self.assertEqual(t_winter.fold, 0)
# What happens in the fall-back fold?
u = datetime(2002, 10, 27, 5, 30, tzinfo=Eastern2)
t0 = Eastern2.fromutc(u)
u += HOUR
t1 = Eastern2.fromutc(u)
self.assertEqual(t0, t1)
self.assertEqual(t0.fold, 0)
self.assertEqual(t1.fold, 1)
# The tricky part is when u is in the local fold:
u = datetime(2002, 10, 27, 1, 30, tzinfo=Eastern2)
t = Eastern2.fromutc(u)
self.assertEqual((t.day, t.hour), (26, 21))
# .. or gets into the local fold after a standard time adjustment
u = datetime(2002, 10, 27, 6, 30, tzinfo=Eastern2)
t = Eastern2.fromutc(u)
self.assertEqual((t.day, t.hour), (27, 1))
# What happens in the spring-forward gap?
u = datetime(2002, 4, 7, 2, 0, tzinfo=Eastern2)
t = Eastern2.fromutc(u)
self.assertEqual((t.day, t.hour), (6, 21))
def test_mixed_compare_regular(self):
t = datetime(2000, 1, 1, tzinfo=Eastern2)
self.assertEqual(t, t.astimezone(timezone.utc))
t = datetime(2000, 6, 1, tzinfo=Eastern2)
self.assertEqual(t, t.astimezone(timezone.utc))
def test_mixed_compare_fold(self):
t_fold = datetime(2002, 10, 27, 1, 45, tzinfo=Eastern2)
t_fold_utc = t_fold.astimezone(timezone.utc)
self.assertNotEqual(t_fold, t_fold_utc)
def test_mixed_compare_gap(self):
t_gap = datetime(2002, 4, 7, 2, 45, tzinfo=Eastern2)
t_gap_utc = t_gap.astimezone(timezone.utc)
self.assertNotEqual(t_gap, t_gap_utc)
def test_hash_aware(self):
t = datetime(2000, 1, 1, tzinfo=Eastern2)
self.assertEqual(hash(t), hash(t.replace(fold=1)))
t_fold = datetime(2002, 10, 27, 1, 45, tzinfo=Eastern2)
t_gap = datetime(2002, 4, 7, 2, 45, tzinfo=Eastern2)
self.assertEqual(hash(t_fold), hash(t_fold.replace(fold=1)))
self.assertEqual(hash(t_gap), hash(t_gap.replace(fold=1)))
SEC = timedelta(0, 1)
def pairs(iterable):
a, b = itertools.tee(iterable)
next(b, None)
return zip(a, b)
class ZoneInfo(tzinfo):
zoneroot = '/usr/share/zoneinfo'
def __init__(self, ut, ti):
"""
:param ut: array
Array of transition point timestamps
:param ti: list
A list of (offset, isdst, abbr) tuples
:return: None
"""
self.ut = ut
self.ti = ti
self.lt = self.invert(ut, ti)
@staticmethod
def invert(ut, ti):
lt = (ut.__copy__(), ut.__copy__())
if ut:
offset = ti[0][0] // SEC
lt[0][0] = max(-2**31, lt[0][0] + offset)
lt[1][0] = max(-2**31, lt[1][0] + offset)
for i in range(1, len(ut)):
lt[0][i] += ti[i-1][0] // SEC
lt[1][i] += ti[i][0] // SEC
return lt
@classmethod
def fromfile(cls, fileobj):
if fileobj.read(4).decode() != "TZif":
raise ValueError("not a zoneinfo file")
fileobj.seek(32)
counts = array('i')
counts.fromfile(fileobj, 3)
if sys.byteorder != 'big':
counts.byteswap()
ut = array('i')
ut.fromfile(fileobj, counts[0])
if sys.byteorder != 'big':
ut.byteswap()
type_indices = array('B')
type_indices.fromfile(fileobj, counts[0])
ttis = []
for i in range(counts[1]):
ttis.append(struct.unpack(">lbb", fileobj.read(6)))
abbrs = fileobj.read(counts[2])
# Convert ttis
for i, (gmtoff, isdst, abbrind) in enumerate(ttis):
abbr = abbrs[abbrind:abbrs.find(0, abbrind)].decode()
ttis[i] = (timedelta(0, gmtoff), isdst, abbr)
ti = [None] * len(ut)
for i, idx in enumerate(type_indices):
ti[i] = ttis[idx]
self = cls(ut, ti)
return self
@classmethod
def fromname(cls, name):
path = os.path.join(cls.zoneroot, name)
with open(path, 'rb') as f:
return cls.fromfile(f)
EPOCHORDINAL = date(1970, 1, 1).toordinal()
def fromutc(self, dt):
"""datetime in UTC -> datetime in local time."""
if not isinstance(dt, datetime):
raise TypeError("fromutc() requires a datetime argument")
if dt.tzinfo is not self:
raise ValueError("dt.tzinfo is not self")
timestamp = ((dt.toordinal() - self.EPOCHORDINAL) * 86400
+ dt.hour * 3600
+ dt.minute * 60
+ dt.second)
if timestamp < self.ut[1]:
tti = self.ti[0]
fold = 0
else:
idx = bisect.bisect_right(self.ut, timestamp)
assert self.ut[idx-1] <= timestamp
assert idx == len(self.ut) or timestamp < self.ut[idx]
tti_prev, tti = self.ti[idx-2:idx]
# Detect fold
shift = tti_prev[0] - tti[0]
fold = (shift > timedelta(0, timestamp - self.ut[idx-1]))
dt += tti[0]
if fold:
return dt.replace(fold=1)
else:
return dt
def _find_ti(self, dt, i):
timestamp = ((dt.toordinal() - self.EPOCHORDINAL) * 86400
+ dt.hour * 3600
+ dt.minute * 60
+ dt.second)
lt = self.lt[dt.fold]
idx = bisect.bisect_right(lt, timestamp)
return self.ti[max(0, idx - 1)][i]
def utcoffset(self, dt):
return self._find_ti(dt, 0)
def dst(self, dt):
isdst = self._find_ti(dt, 1)
# XXX: We cannot accurately determine the "save" value,
# so let's return 1h whenever DST is in effect. Since
# we don't use dst() in fromutc(), it is unlikely that
# it will be needed for anything more than bool(dst()).
return ZERO if isdst else HOUR
def tzname(self, dt):
return self._find_ti(dt, 2)
@classmethod
def zonenames(cls, zonedir=None):
if zonedir is None:
zonedir = cls.zoneroot
for root, _, files in os.walk(zonedir):
for f in files:
p = os.path.join(root, f)
with open(p, 'rb') as o:
magic = o.read(4)
if magic == b'TZif':
yield p[len(zonedir) + 1:]
@classmethod
def stats(cls, start_year=1):
count = gap_count = fold_count = zeros_count = 0
min_gap = min_fold = timedelta.max
max_gap = max_fold = ZERO
min_gap_datetime = max_gap_datetime = datetime.min
min_gap_zone = max_gap_zone = None
min_fold_datetime = max_fold_datetime = datetime.min
min_fold_zone = max_fold_zone = None
stats_since = datetime(start_year, 1, 1) # Starting from 1970 eliminates a lot of noise
for zonename in cls.zonenames():
count += 1
tz = cls.fromname(zonename)
for dt, shift in tz.transitions():
if dt < stats_since:
continue
if shift > ZERO:
gap_count += 1
if (shift, dt) > (max_gap, max_gap_datetime):
max_gap = shift
max_gap_zone = zonename
max_gap_datetime = dt
if (shift, datetime.max - dt) < (min_gap, datetime.max - min_gap_datetime):
min_gap = shift
min_gap_zone = zonename
min_gap_datetime = dt
elif shift < ZERO:
fold_count += 1
shift = -shift
if (shift, dt) > (max_fold, max_fold_datetime):
max_fold = shift
max_fold_zone = zonename
max_fold_datetime = dt
if (shift, datetime.max - dt) < (min_fold, datetime.max - min_fold_datetime):
min_fold = shift
min_fold_zone = zonename
min_fold_datetime = dt
else:
zeros_count += 1
trans_counts = (gap_count, fold_count, zeros_count)
print("Number of zones: %5d" % count)
print("Number of transitions: %5d = %d (gaps) + %d (folds) + %d (zeros)" %
((sum(trans_counts),) + trans_counts))
print("Min gap: %16s at %s in %s" % (min_gap, min_gap_datetime, min_gap_zone))
print("Max gap: %16s at %s in %s" % (max_gap, max_gap_datetime, max_gap_zone))
print("Min fold: %16s at %s in %s" % (min_fold, min_fold_datetime, min_fold_zone))
print("Max fold: %16s at %s in %s" % (max_fold, max_fold_datetime, max_fold_zone))
def transitions(self):
for (_, prev_ti), (t, ti) in pairs(zip(self.ut, self.ti)):
shift = ti[0] - prev_ti[0]
yield datetime.utcfromtimestamp(t), shift
def nondst_folds(self):
"""Find all folds with the same value of isdst on both sides of the transition."""
for (_, prev_ti), (t, ti) in pairs(zip(self.ut, self.ti)):
shift = ti[0] - prev_ti[0]
if shift < ZERO and ti[1] == prev_ti[1]:
yield datetime.utcfromtimestamp(t), -shift, prev_ti[2], ti[2]
@classmethod
def print_all_nondst_folds(cls, same_abbr=False, start_year=1):
count = 0
for zonename in cls.zonenames():
tz = cls.fromname(zonename)
for dt, shift, prev_abbr, abbr in tz.nondst_folds():
if dt.year < start_year or same_abbr and prev_abbr != abbr:
continue
count += 1
print("%3d) %-30s %s %10s %5s -> %s" %
(count, zonename, dt, shift, prev_abbr, abbr))
def folds(self):
for t, shift in self.transitions():
if shift < ZERO:
yield t, -shift
def gaps(self):
for t, shift in self.transitions():
if shift > ZERO:
yield t, shift
def zeros(self):
for t, shift in self.transitions():
if not shift:
yield t
class ZoneInfoTest(unittest.TestCase):
zonename = 'America/New_York'
def setUp(self):
if sys.platform == "win32":
self.skipTest("Skipping zoneinfo tests on Windows")
self.tz = ZoneInfo.fromname(self.zonename)
def assertEquivDatetimes(self, a, b):
self.assertEqual((a.replace(tzinfo=None), a.fold, id(a.tzinfo)),
(b.replace(tzinfo=None), b.fold, id(b.tzinfo)))
def test_folds(self):
tz = self.tz
for dt, shift in tz.folds():
for x in [0 * shift, 0.5 * shift, shift - timedelta.resolution]:
udt = dt + x
ldt = tz.fromutc(udt.replace(tzinfo=tz))
self.assertEqual(ldt.fold, 1)
adt = udt.replace(tzinfo=timezone.utc).astimezone(tz)
self.assertEquivDatetimes(adt, ldt)
utcoffset = ldt.utcoffset()
self.assertEqual(ldt.replace(tzinfo=None), udt + utcoffset)
# Round trip
self.assertEquivDatetimes(ldt.astimezone(timezone.utc),
udt.replace(tzinfo=timezone.utc))
for x in [-timedelta.resolution, shift]:
udt = dt + x
udt = udt.replace(tzinfo=tz)
ldt = tz.fromutc(udt)
self.assertEqual(ldt.fold, 0)
def test_gaps(self):
tz = self.tz
for dt, shift in tz.gaps():
for x in [0 * shift, 0.5 * shift, shift - timedelta.resolution]:
udt = dt + x
udt = udt.replace(tzinfo=tz)
ldt = tz.fromutc(udt)
self.assertEqual(ldt.fold, 0)
adt = udt.replace(tzinfo=timezone.utc).astimezone(tz)
self.assertEquivDatetimes(adt, ldt)
utcoffset = ldt.utcoffset()
self.assertEqual(ldt.replace(tzinfo=None), udt.replace(tzinfo=None) + utcoffset)
# Create a local time inside the gap
ldt = tz.fromutc(dt.replace(tzinfo=tz)) - shift + x
self.assertLess(ldt.replace(fold=1).utcoffset(),
ldt.replace(fold=0).utcoffset(),
"At %s." % ldt)
for x in [-timedelta.resolution, shift]:
udt = dt + x
ldt = tz.fromutc(udt.replace(tzinfo=tz))
self.assertEqual(ldt.fold, 0)
def test_system_transitions(self):
if ('Riyadh8' in self.zonename or
# From tzdata NEWS file:
# The files solar87, solar88, and solar89 are no longer distributed.
# They were a negative experiment - that is, a demonstration that
# tz data can represent solar time only with some difficulty and error.
# Their presence in the distribution caused confusion, as Riyadh
# civil time was generally not solar time in those years.
self.zonename.startswith('right/')):
self.skipTest("Skipping %s" % self.zonename)
tz = ZoneInfo.fromname(self.zonename)
TZ = os.environ.get('TZ')
os.environ['TZ'] = self.zonename
try:
_time.tzset()
for udt, shift in tz.transitions():
if self.zonename == 'Europe/Tallinn' and udt.date() == date(1999, 10, 31):
print("Skip %s %s transition" % (self.zonename, udt))
continue
s0 = (udt - datetime(1970, 1, 1)) // SEC
ss = shift // SEC # shift seconds
for x in [-40 * 3600, -20*3600, -1, 0,
ss - 1, ss + 20 * 3600, ss + 40 * 3600]:
s = s0 + x
sdt = datetime.fromtimestamp(s)
tzdt = datetime.fromtimestamp(s, tz).replace(tzinfo=None)
self.assertEquivDatetimes(sdt, tzdt)
s1 = sdt.timestamp()
self.assertEqual(s, s1)
if ss > 0: # gap
# Create local time inside the gap
dt = datetime.fromtimestamp(s0) - shift / 2
ts0 = dt.timestamp()
ts1 = dt.replace(fold=1).timestamp()
self.assertEqual(ts0, s0 + ss / 2)
self.assertEqual(ts1, s0 - ss / 2)
finally:
if TZ is None:
del os.environ['TZ']
else:
os.environ['TZ'] = TZ
_time.tzset()
class ZoneInfoCompleteTest(unittest.TestCase):
def test_all(self):
requires('tzdata', 'test requires tzdata and a long time to run')
for name in ZoneInfo.zonenames():
class Test(ZoneInfoTest):
zonename = name
for suffix in ['folds', 'gaps', 'system_transitions']:
test = Test('test_' + suffix)
result = test.run()
self.assertTrue(result.wasSuccessful(), name + ' ' + suffix)
# Iran had a sub-minute UTC offset before 1946.
class IranTest(ZoneInfoTest):
zonename = 'Iran'
if __name__ == "__main__":
unittest.main()