summaryrefslogtreecommitdiffstats
path: root/debian/tests/python
blob: bc4db3e61f57961673c2718bdc756ceb6ed4d74f (plain)
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
#!/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 main() -> None:
    """Run unit tests in verbose mode."""
    argv = sys.argv.copy()
    argv.insert(1, "-v")
    unittest.main(argv=argv)


if __name__ == "__main__":
    main()