#!/usr/bin/python3 # Author: Benjamin Drung """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\S+)\t+(?P\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 main() -> None: """Run unit tests in verbose mode.""" argv = sys.argv.copy() argv.insert(1, "-v") unittest.main(argv=argv) if __name__ == "__main__": main()