summaryrefslogtreecommitdiffstats
path: root/debian/tests/python
diff options
context:
space:
mode:
Diffstat (limited to 'debian/tests/python')
-rwxr-xr-xdebian/tests/python179
1 files changed, 179 insertions, 0 deletions
diff --git a/debian/tests/python b/debian/tests/python
new file mode 100755
index 0000000..25044a9
--- /dev/null
+++ b/debian/tests/python
@@ -0,0 +1,179 @@
+#!/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
+
+
+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 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()