1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157
|
#!/usr/bin/python3
# Author: Benjamin Drung <bdrung@ubuntu.com>
"""Test timezones using Python's zoneinfo module."""
import datetime
import os
import pathlib
import re
import sys
import typing
import unittest
import zoneinfo
ROOT_DIR = pathlib.Path(__file__).parent.parent.parent
def read_backwards_links(backwards_file: pathlib.Path) -> dict[str, str]:
"""Read backwards compatibility links from the upstream backwards file."""
backwards_links = {}
for line in backwards_file.read_text(encoding="utf-8").splitlines():
match = re.match(r"^Link\t(?P<target>\S+)\t+(?P<link_name>\S+)", line)
if not match:
continue
backwards_links[match.group("link_name")] = match.group("target")
return backwards_links
class TestZoneinfo(unittest.TestCase):
"""Test timezones using Python's zoneinfo module."""
def _hours(self, delta: typing.Optional[datetime.timedelta]) -> int:
assert delta is not None
total_seconds = int(delta.total_seconds())
self.assertEqual(total_seconds % 3600, 0)
return total_seconds // 3600
def test_available_timezones_count(self) -> None:
"""Test available_timezones() count to be reasonable."""
zones = len(zoneinfo.available_timezones())
self.assertGreaterEqual(zones, 597, "less zones than 2022g-2")
self.assertLess(zones, round(597 * 1.1), ">10% more zones than 2022g-2")
def test_daylight_saving_transition(self) -> None:
"""Test daylight saving time transition from Python documentation."""
tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles")
date = datetime.datetime(2020, 10, 31, 12, tzinfo=tzinfo)
self.assertEqual(date.tzname(), "PDT")
next_day = date + datetime.timedelta(days=1)
self.assertEqual(next_day.tzname(), "PST")
def _assert_equal_zones_at_date(
self,
date: datetime.datetime,
timezone1: zoneinfo.ZoneInfo,
timezone2: zoneinfo.ZoneInfo,
) -> None:
date1 = date.replace(tzinfo=timezone1)
date2 = date.replace(tzinfo=timezone2)
self.assertEqual(date1 - date2, datetime.timedelta(seconds=0))
self.assertEqual(date1.tzname(), date2.tzname())
def _assert_equal_zones(
self, timezone1: zoneinfo.ZoneInfo, timezone2: zoneinfo.ZoneInfo
) -> None:
"""Test timezones to be heuristically equal regardless of the name."""
october_2020 = datetime.datetime(2020, 10, 31, 12)
self._assert_equal_zones_at_date(october_2020, timezone1, timezone2)
july_2021 = datetime.datetime(2021, 7, 3, 12)
self._assert_equal_zones_at_date(july_2021, timezone1, timezone2)
@unittest.skipIf(os.environ.get("PYTHONTZPATH"), "requires installed tzdata")
def test_localtime(self) -> None:
"""Test 'localtime' timezone."""
localtime = pathlib.Path("/etc/localtime")
zone = str(localtime.resolve().relative_to("/usr/share/zoneinfo"))
tzinfo = zoneinfo.ZoneInfo("localtime")
self._assert_equal_zones(tzinfo, zoneinfo.ZoneInfo(zone))
def _test_timezone(self, zone: str) -> None:
"""Test zone to load, have a name, and have a reasonable offset."""
tzinfo = zoneinfo.ZoneInfo(zone)
self.assertEqual(str(tzinfo), zone)
date = datetime.datetime(2020, 10, 31, 12, tzinfo=tzinfo)
tzname = date.tzname()
assert tzname is not None
self.assertGreaterEqual(len(tzname), 3, tzname)
self.assertLessEqual(len(tzname), 5, tzname)
utc_offset = date.utcoffset()
assert utc_offset is not None
self.assertEqual(int(utc_offset.total_seconds()) % 900, 0)
self.assertLessEqual(utc_offset, datetime.timedelta(hours=14))
def test_pre_1970_timestamps(self) -> None:
"""Test pre-1970 timestamps of Berlin and Oslo being different."""
berlin = zoneinfo.ZoneInfo("Europe/Berlin")
date = datetime.datetime(1960, 7, 1, tzinfo=berlin)
self.assertEqual(self._hours(date.utcoffset()), 1)
oslo = zoneinfo.ZoneInfo("Europe/Oslo")
self.assertEqual(self._hours(date.replace(tzinfo=oslo).utcoffset()), 2)
def test_post_1970_symlinks_consistency(self) -> None:
"""Test that post-1970 symlinks are consistent with pre-1970 timezones.
Building tzdata with PACKRATDATA=backzone will result in separate
time zones for time zones that differ only before 1970. These time
zones should behave identical after 1970. Building tzdata without
PACKRATDATA=backzone will result in one of the time zones become a
symlink to the other time zone.
"""
links = read_backwards_links(ROOT_DIR / "backward")
for link_name, target in links.items():
with self.subTest(f"{link_name} -> {target}"):
tz_link = zoneinfo.ZoneInfo(link_name)
tz_target = zoneinfo.ZoneInfo(target)
now = datetime.datetime.now()
self._assert_equal_zones_at_date(now, tz_link, tz_target)
future = now + datetime.timedelta(days=30 * 6)
self._assert_equal_zones_at_date(future, tz_link, tz_target)
def test_timezones(self) -> None:
"""Test all zones to load, have a name, and have a reasonable offset."""
for zone in zoneinfo.available_timezones():
with self.subTest(zone=zone):
self._test_timezone(zone)
def test_2022g(self) -> None:
"""Test new zone America/Ciudad_Juarez from 2022g release."""
tzinfo = zoneinfo.ZoneInfo("America/Ciudad_Juarez")
date = datetime.datetime(2022, 12, 1, tzinfo=tzinfo)
self.assertEqual(self._hours(date.utcoffset()), -7)
def test_2023a(self) -> None:
"""Test Egypt uses DST again from 2023a release."""
tzinfo = zoneinfo.ZoneInfo("Africa/Cairo")
date = datetime.datetime(2023, 4, 28, 12, 0, tzinfo=tzinfo)
self.assertEqual(self._hours(date.utcoffset()), 3)
def test_2023c(self) -> None:
"""Test Lebanon's reverted DST delay from 2023c release."""
tzinfo = zoneinfo.ZoneInfo("Asia/Beirut")
date = datetime.datetime(2023, 4, 2, tzinfo=tzinfo)
self.assertEqual(self._hours(date.utcoffset()), 3)
def main() -> None:
"""Run unit tests in verbose mode."""
argv = sys.argv.copy()
argv.insert(1, "-v")
unittest.main(argv=argv)
if __name__ == "__main__":
main()
|