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 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
|
#!/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
HAS_LEGACY_TIMEZONES = "NO_LEGACY_TIMEZONES" not in os.environ
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
def read_link(link: pathlib.Path) -> pathlib.Path:
"""Return the absolute path to which the symbolic link points."""
destination = link.parent / link.readlink()
return pathlib.Path(os.path.normpath(destination))
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())
expected_zones = 485
if HAS_LEGACY_TIMEZONES:
expected_zones = 597
self.assertGreaterEqual(zones, expected_zones, "less zones than 2023c-8")
self.assertLess(
zones, round(expected_zones * 1.1), ">10% more zones than 2023c-8"
)
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}"):
try:
tz_link = zoneinfo.ZoneInfo(link_name)
except zoneinfo.ZoneInfoNotFoundError:
if not HAS_LEGACY_TIMEZONES:
continue
raise
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 assert_not_symlink_to_symlink(self, timezone_path: pathlib.Path) -> None:
"""Assert that the timezone is not a symlink to another symlink."""
if not timezone_path.is_symlink():
return
destination = read_link(timezone_path)
if not destination.is_symlink():
return
self.fail(
f"Symlink to symlink found: {timezone_path} -> {destination}"
f" -> {read_link(destination)}"
)
def test_no_symlinks_to_symlinks(self) -> None:
"""Check that no timezone is a symlink to another symlink."""
for timezone in sorted(zoneinfo.available_timezones()):
if timezone == "localtime":
continue
with self.subTest(timezone):
for tzpath in zoneinfo.TZPATH:
timezone_path = pathlib.Path(tzpath) / timezone
self.assert_not_symlink_to_symlink(timezone_path)
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 test_2023d(self) -> None:
"""Test 2023d release: Vostok being on +05 from 2023-12-18 on."""
tzinfo = zoneinfo.ZoneInfo("Antarctica/Vostok")
date = datetime.datetime(2023, 12, 19, tzinfo=tzinfo)
self.assertEqual(self._hours(date.utcoffset()), 5)
def test_2024a(self) -> None:
"""Test 2024a release: Kazakhstan being on +05 from 2024-03-01 on."""
tzinfo = zoneinfo.ZoneInfo("Asia/Almaty")
date = datetime.datetime(2024, 3, 2, tzinfo=tzinfo)
self.assertEqual(self._hours(date.utcoffset()), 5)
def test_2024b(self) -> None:
"""Test 2024b release: Azores did not observe DST from 1977 to 1981."""
tzinfo = zoneinfo.ZoneInfo("Atlantic/Azores")
date = datetime.datetime(1980, 7, 3, tzinfo=tzinfo)
self.assertEqual(self._hours(date.utcoffset()), -1)
def test_2025a(self) -> None:
"""Test 2025a release: Paraguary stopped using DST from 2024-10-15 on."""
tzinfo = zoneinfo.ZoneInfo("America/Asuncion")
date = datetime.datetime(2024, 10, 16, tzinfo=tzinfo)
self.assertEqual(tzinfo.dst(date), datetime.timedelta(0))
def test_2025b(self) -> None:
"""Test 2025b release: Coyhaique stopped using DST from 2025-03-20 on."""
tzinfo = zoneinfo.ZoneInfo("America/Coyhaique")
date = datetime.datetime(2025, 3, 21, tzinfo=tzinfo)
self.assertEqual(tzinfo.dst(date), datetime.timedelta(0))
def test_2025c(self) -> None:
"""Test 2025c release: Baja California agreed with California's DST
rules in 1961 through 1975."""
tzinfo = zoneinfo.ZoneInfo("America/Tijuana")
date = datetime.datetime(1961, 5, 1, tzinfo=tzinfo)
self.assertEqual(self._hours(date.utcoffset()), -7)
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()
|