• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1from __future__ import annotations
2
3import base64
4import contextlib
5import dataclasses
6import importlib.metadata
7import io
8import json
9import os
10import pathlib
11import pickle
12import re
13import shutil
14import struct
15import tempfile
16import unittest
17from datetime import date, datetime, time, timedelta, timezone
18from functools import cached_property
19
20from . import _support as test_support
21from ._support import OS_ENV_LOCK, TZPATH_TEST_LOCK, ZoneInfoTestBase
22from test.support.import_helper import import_module
23
24lzma = import_module('lzma')
25py_zoneinfo, c_zoneinfo = test_support.get_modules()
26
27try:
28    importlib.metadata.metadata("tzdata")
29    HAS_TZDATA_PKG = True
30except importlib.metadata.PackageNotFoundError:
31    HAS_TZDATA_PKG = False
32
33ZONEINFO_DATA = None
34ZONEINFO_DATA_V1 = None
35TEMP_DIR = None
36DATA_DIR = pathlib.Path(__file__).parent / "data"
37ZONEINFO_JSON = DATA_DIR / "zoneinfo_data.json"
38
39# Useful constants
40ZERO = timedelta(0)
41ONE_H = timedelta(hours=1)
42
43
44def setUpModule():
45    global TEMP_DIR
46    global ZONEINFO_DATA
47    global ZONEINFO_DATA_V1
48
49    TEMP_DIR = pathlib.Path(tempfile.mkdtemp(prefix="zoneinfo"))
50    ZONEINFO_DATA = ZoneInfoData(ZONEINFO_JSON, TEMP_DIR / "v2")
51    ZONEINFO_DATA_V1 = ZoneInfoData(ZONEINFO_JSON, TEMP_DIR / "v1", v1=True)
52
53
54def tearDownModule():
55    shutil.rmtree(TEMP_DIR)
56
57
58class TzPathUserMixin:
59    """
60    Adds a setUp() and tearDown() to make TZPATH manipulations thread-safe.
61
62    Any tests that require manipulation of the TZPATH global are necessarily
63    thread unsafe, so we will acquire a lock and reset the TZPATH variable
64    to the default state before each test and release the lock after the test
65    is through.
66    """
67
68    @property
69    def tzpath(self):  # pragma: nocover
70        return None
71
72    @property
73    def block_tzdata(self):
74        return True
75
76    def setUp(self):
77        with contextlib.ExitStack() as stack:
78            stack.enter_context(
79                self.tzpath_context(
80                    self.tzpath,
81                    block_tzdata=self.block_tzdata,
82                    lock=TZPATH_TEST_LOCK,
83                )
84            )
85            self.addCleanup(stack.pop_all().close)
86
87        super().setUp()
88
89
90class DatetimeSubclassMixin:
91    """
92    Replaces all ZoneTransition transition dates with a datetime subclass.
93    """
94
95    class DatetimeSubclass(datetime):
96        @classmethod
97        def from_datetime(cls, dt):
98            return cls(
99                dt.year,
100                dt.month,
101                dt.day,
102                dt.hour,
103                dt.minute,
104                dt.second,
105                dt.microsecond,
106                tzinfo=dt.tzinfo,
107                fold=dt.fold,
108            )
109
110    def load_transition_examples(self, key):
111        transition_examples = super().load_transition_examples(key)
112        for zt in transition_examples:
113            dt = zt.transition
114            new_dt = self.DatetimeSubclass.from_datetime(dt)
115            new_zt = dataclasses.replace(zt, transition=new_dt)
116            yield new_zt
117
118
119class ZoneInfoTest(TzPathUserMixin, ZoneInfoTestBase):
120    module = py_zoneinfo
121    class_name = "ZoneInfo"
122
123    def setUp(self):
124        super().setUp()
125
126        # This is necessary because various subclasses pull from different
127        # data sources (e.g. tzdata, V1 files, etc).
128        self.klass.clear_cache()
129
130    @property
131    def zoneinfo_data(self):
132        return ZONEINFO_DATA
133
134    @property
135    def tzpath(self):
136        return [self.zoneinfo_data.tzpath]
137
138    def zone_from_key(self, key):
139        return self.klass(key)
140
141    def zones(self):
142        return ZoneDumpData.transition_keys()
143
144    def fixed_offset_zones(self):
145        return ZoneDumpData.fixed_offset_zones()
146
147    def load_transition_examples(self, key):
148        return ZoneDumpData.load_transition_examples(key)
149
150    def test_str(self):
151        # Zones constructed with a key must have str(zone) == key
152        for key in self.zones():
153            with self.subTest(key):
154                zi = self.zone_from_key(key)
155
156                self.assertEqual(str(zi), key)
157
158        # Zones with no key constructed should have str(zone) == repr(zone)
159        file_key = self.zoneinfo_data.keys[0]
160        file_path = self.zoneinfo_data.path_from_key(file_key)
161
162        with open(file_path, "rb") as f:
163            with self.subTest(test_name="Repr test", path=file_path):
164                zi_ff = self.klass.from_file(f)
165                self.assertEqual(str(zi_ff), repr(zi_ff))
166
167    def test_repr(self):
168        # The repr is not guaranteed, but I think we can insist that it at
169        # least contain the name of the class.
170        key = next(iter(self.zones()))
171
172        zi = self.klass(key)
173        class_name = self.class_name
174        with self.subTest(name="from key"):
175            self.assertRegex(repr(zi), class_name)
176
177        file_key = self.zoneinfo_data.keys[0]
178        file_path = self.zoneinfo_data.path_from_key(file_key)
179        with open(file_path, "rb") as f:
180            zi_ff = self.klass.from_file(f, key=file_key)
181
182        with self.subTest(name="from file with key"):
183            self.assertRegex(repr(zi_ff), class_name)
184
185        with open(file_path, "rb") as f:
186            zi_ff_nk = self.klass.from_file(f)
187
188        with self.subTest(name="from file without key"):
189            self.assertRegex(repr(zi_ff_nk), class_name)
190
191    def test_key_attribute(self):
192        key = next(iter(self.zones()))
193
194        def from_file_nokey(key):
195            with open(self.zoneinfo_data.path_from_key(key), "rb") as f:
196                return self.klass.from_file(f)
197
198        constructors = (
199            ("Primary constructor", self.klass, key),
200            ("no_cache", self.klass.no_cache, key),
201            ("from_file", from_file_nokey, None),
202        )
203
204        for msg, constructor, expected in constructors:
205            zi = constructor(key)
206
207            # Ensure that the key attribute is set to the input to ``key``
208            with self.subTest(msg):
209                self.assertEqual(zi.key, expected)
210
211            # Ensure that the key attribute is read-only
212            with self.subTest(f"{msg}: readonly"):
213                with self.assertRaises(AttributeError):
214                    zi.key = "Some/Value"
215
216    def test_bad_keys(self):
217        bad_keys = [
218            "Eurasia/Badzone",  # Plausible but does not exist
219            "BZQ",
220            "America.Los_Angeles",
221            "����",  # Non-ascii
222            "America/New\ud800York",  # Contains surrogate character
223        ]
224
225        for bad_key in bad_keys:
226            with self.assertRaises(self.module.ZoneInfoNotFoundError):
227                self.klass(bad_key)
228
229    def test_bad_keys_paths(self):
230        bad_keys = [
231            "/America/Los_Angeles",  # Absolute path
232            "America/Los_Angeles/",  # Trailing slash - not normalized
233            "../zoneinfo/America/Los_Angeles",  # Traverses above TZPATH
234            "America/../America/Los_Angeles",  # Not normalized
235            "America/./Los_Angeles",
236        ]
237
238        for bad_key in bad_keys:
239            with self.assertRaises(ValueError):
240                self.klass(bad_key)
241
242    def test_bad_zones(self):
243        bad_zones = [
244            b"",  # Empty file
245            b"AAAA3" + b" " * 15,  # Bad magic
246        ]
247
248        for bad_zone in bad_zones:
249            fobj = io.BytesIO(bad_zone)
250            with self.assertRaises(ValueError):
251                self.klass.from_file(fobj)
252
253    def test_fromutc_errors(self):
254        key = next(iter(self.zones()))
255        zone = self.zone_from_key(key)
256
257        bad_values = [
258            (datetime(2019, 1, 1, tzinfo=timezone.utc), ValueError),
259            (datetime(2019, 1, 1), ValueError),
260            (date(2019, 1, 1), TypeError),
261            (time(0), TypeError),
262            (0, TypeError),
263            ("2019-01-01", TypeError),
264        ]
265
266        for val, exc_type in bad_values:
267            with self.subTest(val=val):
268                with self.assertRaises(exc_type):
269                    zone.fromutc(val)
270
271    def test_utc(self):
272        zi = self.klass("UTC")
273        dt = datetime(2020, 1, 1, tzinfo=zi)
274
275        self.assertEqual(dt.utcoffset(), ZERO)
276        self.assertEqual(dt.dst(), ZERO)
277        self.assertEqual(dt.tzname(), "UTC")
278
279    def test_unambiguous(self):
280        test_cases = []
281        for key in self.zones():
282            for zone_transition in self.load_transition_examples(key):
283                test_cases.append(
284                    (
285                        key,
286                        zone_transition.transition - timedelta(days=2),
287                        zone_transition.offset_before,
288                    )
289                )
290
291                test_cases.append(
292                    (
293                        key,
294                        zone_transition.transition + timedelta(days=2),
295                        zone_transition.offset_after,
296                    )
297                )
298
299        for key, dt, offset in test_cases:
300            with self.subTest(key=key, dt=dt, offset=offset):
301                tzi = self.zone_from_key(key)
302                dt = dt.replace(tzinfo=tzi)
303
304                self.assertEqual(dt.tzname(), offset.tzname, dt)
305                self.assertEqual(dt.utcoffset(), offset.utcoffset, dt)
306                self.assertEqual(dt.dst(), offset.dst, dt)
307
308    def test_folds_and_gaps(self):
309        test_cases = []
310        for key in self.zones():
311            tests = {"folds": [], "gaps": []}
312            for zt in self.load_transition_examples(key):
313                if zt.fold:
314                    test_group = tests["folds"]
315                elif zt.gap:
316                    test_group = tests["gaps"]
317                else:
318                    # Assign a random variable here to disable the peephole
319                    # optimizer so that coverage can see this line.
320                    # See bpo-2506 for more information.
321                    no_peephole_opt = None
322                    continue
323
324                # Cases are of the form key, dt, fold, offset
325                dt = zt.anomaly_start - timedelta(seconds=1)
326                test_group.append((dt, 0, zt.offset_before))
327                test_group.append((dt, 1, zt.offset_before))
328
329                dt = zt.anomaly_start
330                test_group.append((dt, 0, zt.offset_before))
331                test_group.append((dt, 1, zt.offset_after))
332
333                dt = zt.anomaly_start + timedelta(seconds=1)
334                test_group.append((dt, 0, zt.offset_before))
335                test_group.append((dt, 1, zt.offset_after))
336
337                dt = zt.anomaly_end - timedelta(seconds=1)
338                test_group.append((dt, 0, zt.offset_before))
339                test_group.append((dt, 1, zt.offset_after))
340
341                dt = zt.anomaly_end
342                test_group.append((dt, 0, zt.offset_after))
343                test_group.append((dt, 1, zt.offset_after))
344
345                dt = zt.anomaly_end + timedelta(seconds=1)
346                test_group.append((dt, 0, zt.offset_after))
347                test_group.append((dt, 1, zt.offset_after))
348
349            for grp, test_group in tests.items():
350                test_cases.append(((key, grp), test_group))
351
352        for (key, grp), tests in test_cases:
353            with self.subTest(key=key, grp=grp):
354                tzi = self.zone_from_key(key)
355
356                for dt, fold, offset in tests:
357                    dt = dt.replace(fold=fold, tzinfo=tzi)
358
359                    self.assertEqual(dt.tzname(), offset.tzname, dt)
360                    self.assertEqual(dt.utcoffset(), offset.utcoffset, dt)
361                    self.assertEqual(dt.dst(), offset.dst, dt)
362
363    def test_folds_from_utc(self):
364        for key in self.zones():
365            zi = self.zone_from_key(key)
366            with self.subTest(key=key):
367                for zt in self.load_transition_examples(key):
368                    if not zt.fold:
369                        continue
370
371                    dt_utc = zt.transition_utc
372                    dt_before_utc = dt_utc - timedelta(seconds=1)
373                    dt_after_utc = dt_utc + timedelta(seconds=1)
374
375                    dt_before = dt_before_utc.astimezone(zi)
376                    self.assertEqual(dt_before.fold, 0, (dt_before, dt_utc))
377
378                    dt_after = dt_after_utc.astimezone(zi)
379                    self.assertEqual(dt_after.fold, 1, (dt_after, dt_utc))
380
381    def test_time_variable_offset(self):
382        # self.zones() only ever returns variable-offset zones
383        for key in self.zones():
384            zi = self.zone_from_key(key)
385            t = time(11, 15, 1, 34471, tzinfo=zi)
386
387            with self.subTest(key=key):
388                self.assertIs(t.tzname(), None)
389                self.assertIs(t.utcoffset(), None)
390                self.assertIs(t.dst(), None)
391
392    def test_time_fixed_offset(self):
393        for key, offset in self.fixed_offset_zones():
394            zi = self.zone_from_key(key)
395
396            t = time(11, 15, 1, 34471, tzinfo=zi)
397
398            with self.subTest(key=key):
399                self.assertEqual(t.tzname(), offset.tzname)
400                self.assertEqual(t.utcoffset(), offset.utcoffset)
401                self.assertEqual(t.dst(), offset.dst)
402
403
404class CZoneInfoTest(ZoneInfoTest):
405    module = c_zoneinfo
406
407    def test_fold_mutate(self):
408        """Test that fold isn't mutated when no change is necessary.
409
410        The underlying C API is capable of mutating datetime objects, and
411        may rely on the fact that addition of a datetime object returns a
412        new datetime; this test ensures that the input datetime to fromutc
413        is not mutated.
414        """
415
416        def to_subclass(dt):
417            class SameAddSubclass(type(dt)):
418                def __add__(self, other):
419                    if other == timedelta(0):
420                        return self
421
422                    return super().__add__(other)  # pragma: nocover
423
424            return SameAddSubclass(
425                dt.year,
426                dt.month,
427                dt.day,
428                dt.hour,
429                dt.minute,
430                dt.second,
431                dt.microsecond,
432                fold=dt.fold,
433                tzinfo=dt.tzinfo,
434            )
435
436        subclass = [False, True]
437
438        key = "Europe/London"
439        zi = self.zone_from_key(key)
440        for zt in self.load_transition_examples(key):
441            if zt.fold and zt.offset_after.utcoffset == ZERO:
442                example = zt.transition_utc.replace(tzinfo=zi)
443                break
444
445        for subclass in [False, True]:
446            if subclass:
447                dt = to_subclass(example)
448            else:
449                dt = example
450
451            with self.subTest(subclass=subclass):
452                dt_fromutc = zi.fromutc(dt)
453
454                self.assertEqual(dt_fromutc.fold, 1)
455                self.assertEqual(dt.fold, 0)
456
457
458class ZoneInfoDatetimeSubclassTest(DatetimeSubclassMixin, ZoneInfoTest):
459    pass
460
461
462class CZoneInfoDatetimeSubclassTest(DatetimeSubclassMixin, CZoneInfoTest):
463    pass
464
465
466class ZoneInfoSubclassTest(ZoneInfoTest):
467    @classmethod
468    def setUpClass(cls):
469        super().setUpClass()
470
471        class ZISubclass(cls.klass):
472            pass
473
474        cls.class_name = "ZISubclass"
475        cls.parent_klass = cls.klass
476        cls.klass = ZISubclass
477
478    def test_subclass_own_cache(self):
479        base_obj = self.parent_klass("Europe/London")
480        sub_obj = self.klass("Europe/London")
481
482        self.assertIsNot(base_obj, sub_obj)
483        self.assertIsInstance(base_obj, self.parent_klass)
484        self.assertIsInstance(sub_obj, self.klass)
485
486
487class CZoneInfoSubclassTest(ZoneInfoSubclassTest):
488    module = c_zoneinfo
489
490
491class ZoneInfoV1Test(ZoneInfoTest):
492    @property
493    def zoneinfo_data(self):
494        return ZONEINFO_DATA_V1
495
496    def load_transition_examples(self, key):
497        # We will discard zdump examples outside the range epoch +/- 2**31,
498        # because they are not well-supported in Version 1 files.
499        epoch = datetime(1970, 1, 1)
500        max_offset_32 = timedelta(seconds=2 ** 31)
501        min_dt = epoch - max_offset_32
502        max_dt = epoch + max_offset_32
503
504        for zt in ZoneDumpData.load_transition_examples(key):
505            if min_dt <= zt.transition <= max_dt:
506                yield zt
507
508
509class CZoneInfoV1Test(ZoneInfoV1Test):
510    module = c_zoneinfo
511
512
513@unittest.skipIf(
514    not HAS_TZDATA_PKG, "Skipping tzdata-specific tests: tzdata not installed"
515)
516class TZDataTests(ZoneInfoTest):
517    """
518    Runs all the ZoneInfoTest tests, but against the tzdata package
519
520    NOTE: The ZoneDumpData has frozen test data, but tzdata will update, so
521    some of the tests (particularly those related to the far future) may break
522    in the event that the time zone policies in the relevant time zones change.
523    """
524
525    @property
526    def tzpath(self):
527        return []
528
529    @property
530    def block_tzdata(self):
531        return False
532
533    def zone_from_key(self, key):
534        return self.klass(key=key)
535
536
537@unittest.skipIf(
538    not HAS_TZDATA_PKG, "Skipping tzdata-specific tests: tzdata not installed"
539)
540class CTZDataTests(TZDataTests):
541    module = c_zoneinfo
542
543
544class WeirdZoneTest(ZoneInfoTestBase):
545    module = py_zoneinfo
546
547    def test_one_transition(self):
548        LMT = ZoneOffset("LMT", -timedelta(hours=6, minutes=31, seconds=2))
549        STD = ZoneOffset("STD", -timedelta(hours=6))
550
551        transitions = [
552            ZoneTransition(datetime(1883, 6, 9, 14), LMT, STD),
553        ]
554
555        after = "STD6"
556
557        zf = self.construct_zone(transitions, after)
558        zi = self.klass.from_file(zf)
559
560        dt0 = datetime(1883, 6, 9, 1, tzinfo=zi)
561        dt1 = datetime(1883, 6, 10, 1, tzinfo=zi)
562
563        for dt, offset in [(dt0, LMT), (dt1, STD)]:
564            with self.subTest(name="local", dt=dt):
565                self.assertEqual(dt.tzname(), offset.tzname)
566                self.assertEqual(dt.utcoffset(), offset.utcoffset)
567                self.assertEqual(dt.dst(), offset.dst)
568
569        dts = [
570            (
571                datetime(1883, 6, 9, 1, tzinfo=zi),
572                datetime(1883, 6, 9, 7, 31, 2, tzinfo=timezone.utc),
573            ),
574            (
575                datetime(2010, 4, 1, 12, tzinfo=zi),
576                datetime(2010, 4, 1, 18, tzinfo=timezone.utc),
577            ),
578        ]
579
580        for dt_local, dt_utc in dts:
581            with self.subTest(name="fromutc", dt=dt_local):
582                dt_actual = dt_utc.astimezone(zi)
583                self.assertEqual(dt_actual, dt_local)
584
585                dt_utc_actual = dt_local.astimezone(timezone.utc)
586                self.assertEqual(dt_utc_actual, dt_utc)
587
588    def test_one_zone_dst(self):
589        DST = ZoneOffset("DST", ONE_H, ONE_H)
590        transitions = [
591            ZoneTransition(datetime(1970, 1, 1), DST, DST),
592        ]
593
594        after = "STD0DST-1,0/0,J365/25"
595
596        zf = self.construct_zone(transitions, after)
597        zi = self.klass.from_file(zf)
598
599        dts = [
600            datetime(1900, 3, 1),
601            datetime(1965, 9, 12),
602            datetime(1970, 1, 1),
603            datetime(2010, 11, 3),
604            datetime(2040, 1, 1),
605        ]
606
607        for dt in dts:
608            dt = dt.replace(tzinfo=zi)
609            with self.subTest(dt=dt):
610                self.assertEqual(dt.tzname(), DST.tzname)
611                self.assertEqual(dt.utcoffset(), DST.utcoffset)
612                self.assertEqual(dt.dst(), DST.dst)
613
614    def test_no_tz_str(self):
615        STD = ZoneOffset("STD", ONE_H, ZERO)
616        DST = ZoneOffset("DST", 2 * ONE_H, ONE_H)
617
618        transitions = []
619        for year in range(1996, 2000):
620            transitions.append(
621                ZoneTransition(datetime(year, 3, 1, 2), STD, DST)
622            )
623            transitions.append(
624                ZoneTransition(datetime(year, 11, 1, 2), DST, STD)
625            )
626
627        after = ""
628
629        zf = self.construct_zone(transitions, after)
630
631        # According to RFC 8536, local times after the last transition time
632        # with an empty TZ string are unspecified. We will go with "hold the
633        # last transition", but the most we should promise is "doesn't crash."
634        zi = self.klass.from_file(zf)
635
636        cases = [
637            (datetime(1995, 1, 1), STD),
638            (datetime(1996, 4, 1), DST),
639            (datetime(1996, 11, 2), STD),
640            (datetime(2001, 1, 1), STD),
641        ]
642
643        for dt, offset in cases:
644            dt = dt.replace(tzinfo=zi)
645            with self.subTest(dt=dt):
646                self.assertEqual(dt.tzname(), offset.tzname)
647                self.assertEqual(dt.utcoffset(), offset.utcoffset)
648                self.assertEqual(dt.dst(), offset.dst)
649
650        # Test that offsets return None when using a datetime.time
651        t = time(0, tzinfo=zi)
652        with self.subTest("Testing datetime.time"):
653            self.assertIs(t.tzname(), None)
654            self.assertIs(t.utcoffset(), None)
655            self.assertIs(t.dst(), None)
656
657    def test_tz_before_only(self):
658        # From RFC 8536 Section 3.2:
659        #
660        #   If there are no transitions, local time for all timestamps is
661        #   specified by the TZ string in the footer if present and nonempty;
662        #   otherwise, it is specified by time type 0.
663
664        offsets = [
665            ZoneOffset("STD", ZERO, ZERO),
666            ZoneOffset("DST", ONE_H, ONE_H),
667        ]
668
669        for offset in offsets:
670            # Phantom transition to set time type 0.
671            transitions = [
672                ZoneTransition(None, offset, offset),
673            ]
674
675            after = ""
676
677            zf = self.construct_zone(transitions, after)
678            zi = self.klass.from_file(zf)
679
680            dts = [
681                datetime(1900, 1, 1),
682                datetime(1970, 1, 1),
683                datetime(2000, 1, 1),
684            ]
685
686            for dt in dts:
687                dt = dt.replace(tzinfo=zi)
688                with self.subTest(offset=offset, dt=dt):
689                    self.assertEqual(dt.tzname(), offset.tzname)
690                    self.assertEqual(dt.utcoffset(), offset.utcoffset)
691                    self.assertEqual(dt.dst(), offset.dst)
692
693    def test_empty_zone(self):
694        zf = self.construct_zone([], "")
695
696        with self.assertRaises(ValueError):
697            self.klass.from_file(zf)
698
699    def test_zone_very_large_timestamp(self):
700        """Test when a transition is in the far past or future.
701
702        Particularly, this is a concern if something:
703
704            1. Attempts to call ``datetime.timestamp`` for a datetime outside
705               of ``[datetime.min, datetime.max]``.
706            2. Attempts to construct a timedelta outside of
707               ``[timedelta.min, timedelta.max]``.
708
709        This actually occurs "in the wild", as some time zones on Ubuntu (at
710        least as of 2020) have an initial transition added at ``-2**58``.
711        """
712
713        LMT = ZoneOffset("LMT", timedelta(seconds=-968))
714        GMT = ZoneOffset("GMT", ZERO)
715
716        transitions = [
717            (-(1 << 62), LMT, LMT),
718            ZoneTransition(datetime(1912, 1, 1), LMT, GMT),
719            ((1 << 62), GMT, GMT),
720        ]
721
722        after = "GMT0"
723
724        zf = self.construct_zone(transitions, after)
725        zi = self.klass.from_file(zf, key="Africa/Abidjan")
726
727        offset_cases = [
728            (datetime.min, LMT),
729            (datetime.max, GMT),
730            (datetime(1911, 12, 31), LMT),
731            (datetime(1912, 1, 2), GMT),
732        ]
733
734        for dt_naive, offset in offset_cases:
735            dt = dt_naive.replace(tzinfo=zi)
736            with self.subTest(name="offset", dt=dt, offset=offset):
737                self.assertEqual(dt.tzname(), offset.tzname)
738                self.assertEqual(dt.utcoffset(), offset.utcoffset)
739                self.assertEqual(dt.dst(), offset.dst)
740
741        utc_cases = [
742            (datetime.min, datetime.min + timedelta(seconds=968)),
743            (datetime(1898, 12, 31, 23, 43, 52), datetime(1899, 1, 1)),
744            (
745                datetime(1911, 12, 31, 23, 59, 59, 999999),
746                datetime(1912, 1, 1, 0, 16, 7, 999999),
747            ),
748            (datetime(1912, 1, 1, 0, 16, 8), datetime(1912, 1, 1, 0, 16, 8)),
749            (datetime(1970, 1, 1), datetime(1970, 1, 1)),
750            (datetime.max, datetime.max),
751        ]
752
753        for naive_dt, naive_dt_utc in utc_cases:
754            dt = naive_dt.replace(tzinfo=zi)
755            dt_utc = naive_dt_utc.replace(tzinfo=timezone.utc)
756
757            self.assertEqual(dt_utc.astimezone(zi), dt)
758            self.assertEqual(dt, dt_utc)
759
760    def test_fixed_offset_phantom_transition(self):
761        UTC = ZoneOffset("UTC", ZERO, ZERO)
762
763        transitions = [ZoneTransition(datetime(1970, 1, 1), UTC, UTC)]
764
765        after = "UTC0"
766        zf = self.construct_zone(transitions, after)
767        zi = self.klass.from_file(zf, key="UTC")
768
769        dt = datetime(2020, 1, 1, tzinfo=zi)
770        with self.subTest("datetime.datetime"):
771            self.assertEqual(dt.tzname(), UTC.tzname)
772            self.assertEqual(dt.utcoffset(), UTC.utcoffset)
773            self.assertEqual(dt.dst(), UTC.dst)
774
775        t = time(0, tzinfo=zi)
776        with self.subTest("datetime.time"):
777            self.assertEqual(t.tzname(), UTC.tzname)
778            self.assertEqual(t.utcoffset(), UTC.utcoffset)
779            self.assertEqual(t.dst(), UTC.dst)
780
781    def construct_zone(self, transitions, after=None, version=3):
782        # These are not used for anything, so we're not going to include
783        # them for now.
784        isutc = []
785        isstd = []
786        leap_seconds = []
787
788        offset_lists = [[], []]
789        trans_times_lists = [[], []]
790        trans_idx_lists = [[], []]
791
792        v1_range = (-(2 ** 31), 2 ** 31)
793        v2_range = (-(2 ** 63), 2 ** 63)
794        ranges = [v1_range, v2_range]
795
796        def zt_as_tuple(zt):
797            # zt may be a tuple (timestamp, offset_before, offset_after) or
798            # a ZoneTransition object — this is to allow the timestamp to be
799            # values that are outside the valid range for datetimes but still
800            # valid 64-bit timestamps.
801            if isinstance(zt, tuple):
802                return zt
803
804            if zt.transition:
805                trans_time = int(zt.transition_utc.timestamp())
806            else:
807                trans_time = None
808
809            return (trans_time, zt.offset_before, zt.offset_after)
810
811        transitions = sorted(map(zt_as_tuple, transitions), key=lambda x: x[0])
812
813        for zt in transitions:
814            trans_time, offset_before, offset_after = zt
815
816            for v, (dt_min, dt_max) in enumerate(ranges):
817                offsets = offset_lists[v]
818                trans_times = trans_times_lists[v]
819                trans_idx = trans_idx_lists[v]
820
821                if trans_time is not None and not (
822                    dt_min <= trans_time <= dt_max
823                ):
824                    continue
825
826                if offset_before not in offsets:
827                    offsets.append(offset_before)
828
829                if offset_after not in offsets:
830                    offsets.append(offset_after)
831
832                if trans_time is not None:
833                    trans_times.append(trans_time)
834                    trans_idx.append(offsets.index(offset_after))
835
836        isutcnt = len(isutc)
837        isstdcnt = len(isstd)
838        leapcnt = len(leap_seconds)
839
840        zonefile = io.BytesIO()
841
842        time_types = ("l", "q")
843        for v in range(min((version, 2))):
844            offsets = offset_lists[v]
845            trans_times = trans_times_lists[v]
846            trans_idx = trans_idx_lists[v]
847            time_type = time_types[v]
848
849            # Translate the offsets into something closer to the C values
850            abbrstr = bytearray()
851            ttinfos = []
852
853            for offset in offsets:
854                utcoff = int(offset.utcoffset.total_seconds())
855                isdst = bool(offset.dst)
856                abbrind = len(abbrstr)
857
858                ttinfos.append((utcoff, isdst, abbrind))
859                abbrstr += offset.tzname.encode("ascii") + b"\x00"
860            abbrstr = bytes(abbrstr)
861
862            typecnt = len(offsets)
863            timecnt = len(trans_times)
864            charcnt = len(abbrstr)
865
866            # Write the header
867            zonefile.write(b"TZif")
868            zonefile.write(b"%d" % version)
869            zonefile.write(b" " * 15)
870            zonefile.write(
871                struct.pack(
872                    ">6l", isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt
873                )
874            )
875
876            # Now the transition data
877            zonefile.write(struct.pack(f">{timecnt}{time_type}", *trans_times))
878            zonefile.write(struct.pack(f">{timecnt}B", *trans_idx))
879
880            for ttinfo in ttinfos:
881                zonefile.write(struct.pack(">lbb", *ttinfo))
882
883            zonefile.write(bytes(abbrstr))
884
885            # Now the metadata and leap seconds
886            zonefile.write(struct.pack(f"{isutcnt}b", *isutc))
887            zonefile.write(struct.pack(f"{isstdcnt}b", *isstd))
888            zonefile.write(struct.pack(f">{leapcnt}l", *leap_seconds))
889
890            # Finally we write the TZ string if we're writing a Version 2+ file
891            if v > 0:
892                zonefile.write(b"\x0A")
893                zonefile.write(after.encode("ascii"))
894                zonefile.write(b"\x0A")
895
896        zonefile.seek(0)
897        return zonefile
898
899
900class CWeirdZoneTest(WeirdZoneTest):
901    module = c_zoneinfo
902
903
904class TZStrTest(ZoneInfoTestBase):
905    module = py_zoneinfo
906
907    NORMAL = 0
908    FOLD = 1
909    GAP = 2
910
911    @classmethod
912    def setUpClass(cls):
913        super().setUpClass()
914
915        cls._populate_test_cases()
916        cls.populate_tzstr_header()
917
918    @classmethod
919    def populate_tzstr_header(cls):
920        out = bytearray()
921        # The TZif format always starts with a Version 1 file followed by
922        # the Version 2+ file. In this case, we have no transitions, just
923        # the tzstr in the footer, so up to the footer, the files are
924        # identical and we can just write the same file twice in a row.
925        for _ in range(2):
926            out += b"TZif"  # Magic value
927            out += b"3"  # Version
928            out += b" " * 15  # Reserved
929
930            # We will not write any of the manual transition parts
931            out += struct.pack(">6l", 0, 0, 0, 0, 0, 0)
932
933        cls._tzif_header = bytes(out)
934
935    def zone_from_tzstr(self, tzstr):
936        """Creates a zoneinfo file following a POSIX rule."""
937        zonefile = io.BytesIO(self._tzif_header)
938        zonefile.seek(0, 2)
939
940        # Write the footer
941        zonefile.write(b"\x0A")
942        zonefile.write(tzstr.encode("ascii"))
943        zonefile.write(b"\x0A")
944
945        zonefile.seek(0)
946
947        return self.klass.from_file(zonefile, key=tzstr)
948
949    def test_tzstr_localized(self):
950        for tzstr, cases in self.test_cases.items():
951            with self.subTest(tzstr=tzstr):
952                zi = self.zone_from_tzstr(tzstr)
953
954            for dt_naive, offset, _ in cases:
955                dt = dt_naive.replace(tzinfo=zi)
956
957                with self.subTest(tzstr=tzstr, dt=dt, offset=offset):
958                    self.assertEqual(dt.tzname(), offset.tzname)
959                    self.assertEqual(dt.utcoffset(), offset.utcoffset)
960                    self.assertEqual(dt.dst(), offset.dst)
961
962    def test_tzstr_from_utc(self):
963        for tzstr, cases in self.test_cases.items():
964            with self.subTest(tzstr=tzstr):
965                zi = self.zone_from_tzstr(tzstr)
966
967            for dt_naive, offset, dt_type in cases:
968                if dt_type == self.GAP:
969                    continue  # Cannot create a gap from UTC
970
971                dt_utc = (dt_naive - offset.utcoffset).replace(
972                    tzinfo=timezone.utc
973                )
974
975                # Check that we can go UTC -> Our zone
976                dt_act = dt_utc.astimezone(zi)
977                dt_exp = dt_naive.replace(tzinfo=zi)
978
979                self.assertEqual(dt_act, dt_exp)
980
981                if dt_type == self.FOLD:
982                    self.assertEqual(dt_act.fold, dt_naive.fold, dt_naive)
983                else:
984                    self.assertEqual(dt_act.fold, 0)
985
986                # Now check that we can go our zone -> UTC
987                dt_act = dt_exp.astimezone(timezone.utc)
988
989                self.assertEqual(dt_act, dt_utc)
990
991    def test_invalid_tzstr(self):
992        invalid_tzstrs = [
993            "PST8PDT",  # DST but no transition specified
994            "+11",  # Unquoted alphanumeric
995            "GMT,M3.2.0/2,M11.1.0/3",  # Transition rule but no DST
996            "GMT0+11,M3.2.0/2,M11.1.0/3",  # Unquoted alphanumeric in DST
997            "PST8PDT,M3.2.0/2",  # Only one transition rule
998            # Invalid offsets
999            "STD+25",
1000            "STD-25",
1001            "STD+374",
1002            "STD+374DST,M3.2.0/2,M11.1.0/3",
1003            "STD+23DST+25,M3.2.0/2,M11.1.0/3",
1004            "STD-23DST-25,M3.2.0/2,M11.1.0/3",
1005            # Completely invalid dates
1006            "AAA4BBB,M1443339,M11.1.0/3",
1007            "AAA4BBB,M3.2.0/2,0349309483959c",
1008            # Invalid months
1009            "AAA4BBB,M13.1.1/2,M1.1.1/2",
1010            "AAA4BBB,M1.1.1/2,M13.1.1/2",
1011            "AAA4BBB,M0.1.1/2,M1.1.1/2",
1012            "AAA4BBB,M1.1.1/2,M0.1.1/2",
1013            # Invalid weeks
1014            "AAA4BBB,M1.6.1/2,M1.1.1/2",
1015            "AAA4BBB,M1.1.1/2,M1.6.1/2",
1016            # Invalid weekday
1017            "AAA4BBB,M1.1.7/2,M2.1.1/2",
1018            "AAA4BBB,M1.1.1/2,M2.1.7/2",
1019            # Invalid numeric offset
1020            "AAA4BBB,-1/2,20/2",
1021            "AAA4BBB,1/2,-1/2",
1022            "AAA4BBB,367,20/2",
1023            "AAA4BBB,1/2,367/2",
1024            # Invalid julian offset
1025            "AAA4BBB,J0/2,J20/2",
1026            "AAA4BBB,J20/2,J366/2",
1027        ]
1028
1029        for invalid_tzstr in invalid_tzstrs:
1030            with self.subTest(tzstr=invalid_tzstr):
1031                # Not necessarily a guaranteed property, but we should show
1032                # the problematic TZ string if that's the cause of failure.
1033                tzstr_regex = re.escape(invalid_tzstr)
1034                with self.assertRaisesRegex(ValueError, tzstr_regex):
1035                    self.zone_from_tzstr(invalid_tzstr)
1036
1037    @classmethod
1038    def _populate_test_cases(cls):
1039        # This method uses a somewhat unusual style in that it populates the
1040        # test cases for each tzstr by using a decorator to automatically call
1041        # a function that mutates the current dictionary of test cases.
1042        #
1043        # The population of the test cases is done in individual functions to
1044        # give each set of test cases its own namespace in which to define
1045        # its offsets (this way we don't have to worry about variable reuse
1046        # causing problems if someone makes a typo).
1047        #
1048        # The decorator for calling is used to make it more obvious that each
1049        # function is actually called (if it's not decorated, it's not called).
1050        def call(f):
1051            """Decorator to call the addition methods.
1052
1053            This will call a function which adds at least one new entry into
1054            the `cases` dictionary. The decorator will also assert that
1055            something was added to the dictionary.
1056            """
1057            prev_len = len(cases)
1058            f()
1059            assert len(cases) > prev_len, "Function did not add a test case!"
1060
1061        NORMAL = cls.NORMAL
1062        FOLD = cls.FOLD
1063        GAP = cls.GAP
1064
1065        cases = {}
1066
1067        @call
1068        def _add():
1069            # Transition to EDT on the 2nd Sunday in March at 4 AM, and
1070            # transition back on the first Sunday in November at 3AM
1071            tzstr = "EST5EDT,M3.2.0/4:00,M11.1.0/3:00"
1072
1073            EST = ZoneOffset("EST", timedelta(hours=-5), ZERO)
1074            EDT = ZoneOffset("EDT", timedelta(hours=-4), ONE_H)
1075
1076            cases[tzstr] = (
1077                (datetime(2019, 3, 9), EST, NORMAL),
1078                (datetime(2019, 3, 10, 3, 59), EST, NORMAL),
1079                (datetime(2019, 3, 10, 4, 0, fold=0), EST, GAP),
1080                (datetime(2019, 3, 10, 4, 0, fold=1), EDT, GAP),
1081                (datetime(2019, 3, 10, 4, 1, fold=0), EST, GAP),
1082                (datetime(2019, 3, 10, 4, 1, fold=1), EDT, GAP),
1083                (datetime(2019, 11, 2), EDT, NORMAL),
1084                (datetime(2019, 11, 3, 1, 59, fold=1), EDT, NORMAL),
1085                (datetime(2019, 11, 3, 2, 0, fold=0), EDT, FOLD),
1086                (datetime(2019, 11, 3, 2, 0, fold=1), EST, FOLD),
1087                (datetime(2020, 3, 8, 3, 59), EST, NORMAL),
1088                (datetime(2020, 3, 8, 4, 0, fold=0), EST, GAP),
1089                (datetime(2020, 3, 8, 4, 0, fold=1), EDT, GAP),
1090                (datetime(2020, 11, 1, 1, 59, fold=1), EDT, NORMAL),
1091                (datetime(2020, 11, 1, 2, 0, fold=0), EDT, FOLD),
1092                (datetime(2020, 11, 1, 2, 0, fold=1), EST, FOLD),
1093            )
1094
1095        @call
1096        def _add():
1097            # Transition to BST happens on the last Sunday in March at 1 AM GMT
1098            # and the transition back happens the last Sunday in October at 2AM BST
1099            tzstr = "GMT0BST-1,M3.5.0/1:00,M10.5.0/2:00"
1100
1101            GMT = ZoneOffset("GMT", ZERO, ZERO)
1102            BST = ZoneOffset("BST", ONE_H, ONE_H)
1103
1104            cases[tzstr] = (
1105                (datetime(2019, 3, 30), GMT, NORMAL),
1106                (datetime(2019, 3, 31, 0, 59), GMT, NORMAL),
1107                (datetime(2019, 3, 31, 2, 0), BST, NORMAL),
1108                (datetime(2019, 10, 26), BST, NORMAL),
1109                (datetime(2019, 10, 27, 0, 59, fold=1), BST, NORMAL),
1110                (datetime(2019, 10, 27, 1, 0, fold=0), BST, GAP),
1111                (datetime(2019, 10, 27, 2, 0, fold=1), GMT, GAP),
1112                (datetime(2020, 3, 29, 0, 59), GMT, NORMAL),
1113                (datetime(2020, 3, 29, 2, 0), BST, NORMAL),
1114                (datetime(2020, 10, 25, 0, 59, fold=1), BST, NORMAL),
1115                (datetime(2020, 10, 25, 1, 0, fold=0), BST, FOLD),
1116                (datetime(2020, 10, 25, 2, 0, fold=1), GMT, NORMAL),
1117            )
1118
1119        @call
1120        def _add():
1121            # Austrialian time zone - DST start is chronologically first
1122            tzstr = "AEST-10AEDT,M10.1.0/2,M4.1.0/3"
1123
1124            AEST = ZoneOffset("AEST", timedelta(hours=10), ZERO)
1125            AEDT = ZoneOffset("AEDT", timedelta(hours=11), ONE_H)
1126
1127            cases[tzstr] = (
1128                (datetime(2019, 4, 6), AEDT, NORMAL),
1129                (datetime(2019, 4, 7, 1, 59), AEDT, NORMAL),
1130                (datetime(2019, 4, 7, 1, 59, fold=1), AEDT, NORMAL),
1131                (datetime(2019, 4, 7, 2, 0, fold=0), AEDT, FOLD),
1132                (datetime(2019, 4, 7, 2, 1, fold=0), AEDT, FOLD),
1133                (datetime(2019, 4, 7, 2, 0, fold=1), AEST, FOLD),
1134                (datetime(2019, 4, 7, 2, 1, fold=1), AEST, FOLD),
1135                (datetime(2019, 4, 7, 3, 0, fold=0), AEST, NORMAL),
1136                (datetime(2019, 4, 7, 3, 0, fold=1), AEST, NORMAL),
1137                (datetime(2019, 10, 5, 0), AEST, NORMAL),
1138                (datetime(2019, 10, 6, 1, 59), AEST, NORMAL),
1139                (datetime(2019, 10, 6, 2, 0, fold=0), AEST, GAP),
1140                (datetime(2019, 10, 6, 2, 0, fold=1), AEDT, GAP),
1141                (datetime(2019, 10, 6, 3, 0), AEDT, NORMAL),
1142            )
1143
1144        @call
1145        def _add():
1146            # Irish time zone - negative DST
1147            tzstr = "IST-1GMT0,M10.5.0,M3.5.0/1"
1148
1149            GMT = ZoneOffset("GMT", ZERO, -ONE_H)
1150            IST = ZoneOffset("IST", ONE_H, ZERO)
1151
1152            cases[tzstr] = (
1153                (datetime(2019, 3, 30), GMT, NORMAL),
1154                (datetime(2019, 3, 31, 0, 59), GMT, NORMAL),
1155                (datetime(2019, 3, 31, 2, 0), IST, NORMAL),
1156                (datetime(2019, 10, 26), IST, NORMAL),
1157                (datetime(2019, 10, 27, 0, 59, fold=1), IST, NORMAL),
1158                (datetime(2019, 10, 27, 1, 0, fold=0), IST, FOLD),
1159                (datetime(2019, 10, 27, 1, 0, fold=1), GMT, FOLD),
1160                (datetime(2019, 10, 27, 2, 0, fold=1), GMT, NORMAL),
1161                (datetime(2020, 3, 29, 0, 59), GMT, NORMAL),
1162                (datetime(2020, 3, 29, 2, 0), IST, NORMAL),
1163                (datetime(2020, 10, 25, 0, 59, fold=1), IST, NORMAL),
1164                (datetime(2020, 10, 25, 1, 0, fold=0), IST, FOLD),
1165                (datetime(2020, 10, 25, 2, 0, fold=1), GMT, NORMAL),
1166            )
1167
1168        @call
1169        def _add():
1170            # Pacific/Kosrae: Fixed offset zone with a quoted numerical tzname
1171            tzstr = "<+11>-11"
1172
1173            cases[tzstr] = (
1174                (
1175                    datetime(2020, 1, 1),
1176                    ZoneOffset("+11", timedelta(hours=11)),
1177                    NORMAL,
1178                ),
1179            )
1180
1181        @call
1182        def _add():
1183            # Quoted STD and DST, transitions at 24:00
1184            tzstr = "<-04>4<-03>,M9.1.6/24,M4.1.6/24"
1185
1186            M04 = ZoneOffset("-04", timedelta(hours=-4))
1187            M03 = ZoneOffset("-03", timedelta(hours=-3), ONE_H)
1188
1189            cases[tzstr] = (
1190                (datetime(2020, 5, 1), M04, NORMAL),
1191                (datetime(2020, 11, 1), M03, NORMAL),
1192            )
1193
1194        @call
1195        def _add():
1196            # Permanent daylight saving time is modeled with transitions at 0/0
1197            # and J365/25, as mentioned in RFC 8536 Section 3.3.1
1198            tzstr = "EST5EDT,0/0,J365/25"
1199
1200            EDT = ZoneOffset("EDT", timedelta(hours=-4), ONE_H)
1201
1202            cases[tzstr] = (
1203                (datetime(2019, 1, 1), EDT, NORMAL),
1204                (datetime(2019, 6, 1), EDT, NORMAL),
1205                (datetime(2019, 12, 31, 23, 59, 59, 999999), EDT, NORMAL),
1206                (datetime(2020, 1, 1), EDT, NORMAL),
1207                (datetime(2020, 3, 1), EDT, NORMAL),
1208                (datetime(2020, 6, 1), EDT, NORMAL),
1209                (datetime(2020, 12, 31, 23, 59, 59, 999999), EDT, NORMAL),
1210                (datetime(2400, 1, 1), EDT, NORMAL),
1211                (datetime(2400, 3, 1), EDT, NORMAL),
1212                (datetime(2400, 12, 31, 23, 59, 59, 999999), EDT, NORMAL),
1213            )
1214
1215        @call
1216        def _add():
1217            # Transitions on March 1st and November 1st of each year
1218            tzstr = "AAA3BBB,J60/12,J305/12"
1219
1220            AAA = ZoneOffset("AAA", timedelta(hours=-3))
1221            BBB = ZoneOffset("BBB", timedelta(hours=-2), ONE_H)
1222
1223            cases[tzstr] = (
1224                (datetime(2019, 1, 1), AAA, NORMAL),
1225                (datetime(2019, 2, 28), AAA, NORMAL),
1226                (datetime(2019, 3, 1, 11, 59), AAA, NORMAL),
1227                (datetime(2019, 3, 1, 12, fold=0), AAA, GAP),
1228                (datetime(2019, 3, 1, 12, fold=1), BBB, GAP),
1229                (datetime(2019, 3, 1, 13), BBB, NORMAL),
1230                (datetime(2019, 11, 1, 10, 59), BBB, NORMAL),
1231                (datetime(2019, 11, 1, 11, fold=0), BBB, FOLD),
1232                (datetime(2019, 11, 1, 11, fold=1), AAA, FOLD),
1233                (datetime(2019, 11, 1, 12), AAA, NORMAL),
1234                (datetime(2019, 12, 31, 23, 59, 59, 999999), AAA, NORMAL),
1235                (datetime(2020, 1, 1), AAA, NORMAL),
1236                (datetime(2020, 2, 29), AAA, NORMAL),
1237                (datetime(2020, 3, 1, 11, 59), AAA, NORMAL),
1238                (datetime(2020, 3, 1, 12, fold=0), AAA, GAP),
1239                (datetime(2020, 3, 1, 12, fold=1), BBB, GAP),
1240                (datetime(2020, 3, 1, 13), BBB, NORMAL),
1241                (datetime(2020, 11, 1, 10, 59), BBB, NORMAL),
1242                (datetime(2020, 11, 1, 11, fold=0), BBB, FOLD),
1243                (datetime(2020, 11, 1, 11, fold=1), AAA, FOLD),
1244                (datetime(2020, 11, 1, 12), AAA, NORMAL),
1245                (datetime(2020, 12, 31, 23, 59, 59, 999999), AAA, NORMAL),
1246            )
1247
1248        @call
1249        def _add():
1250            # Taken from America/Godthab, this rule has a transition on the
1251            # Saturday before the last Sunday of March and October, at 22:00
1252            # and 23:00, respectively. This is encoded with negative start
1253            # and end transition times.
1254            tzstr = "<-03>3<-02>,M3.5.0/-2,M10.5.0/-1"
1255
1256            N03 = ZoneOffset("-03", timedelta(hours=-3))
1257            N02 = ZoneOffset("-02", timedelta(hours=-2), ONE_H)
1258
1259            cases[tzstr] = (
1260                (datetime(2020, 3, 27), N03, NORMAL),
1261                (datetime(2020, 3, 28, 21, 59, 59), N03, NORMAL),
1262                (datetime(2020, 3, 28, 22, fold=0), N03, GAP),
1263                (datetime(2020, 3, 28, 22, fold=1), N02, GAP),
1264                (datetime(2020, 3, 28, 23), N02, NORMAL),
1265                (datetime(2020, 10, 24, 21), N02, NORMAL),
1266                (datetime(2020, 10, 24, 22, fold=0), N02, FOLD),
1267                (datetime(2020, 10, 24, 22, fold=1), N03, FOLD),
1268                (datetime(2020, 10, 24, 23), N03, NORMAL),
1269            )
1270
1271        @call
1272        def _add():
1273            # Transition times with minutes and seconds
1274            tzstr = "AAA3BBB,M3.2.0/01:30,M11.1.0/02:15:45"
1275
1276            AAA = ZoneOffset("AAA", timedelta(hours=-3))
1277            BBB = ZoneOffset("BBB", timedelta(hours=-2), ONE_H)
1278
1279            cases[tzstr] = (
1280                (datetime(2012, 3, 11, 1, 0), AAA, NORMAL),
1281                (datetime(2012, 3, 11, 1, 30, fold=0), AAA, GAP),
1282                (datetime(2012, 3, 11, 1, 30, fold=1), BBB, GAP),
1283                (datetime(2012, 3, 11, 2, 30), BBB, NORMAL),
1284                (datetime(2012, 11, 4, 1, 15, 44, 999999), BBB, NORMAL),
1285                (datetime(2012, 11, 4, 1, 15, 45, fold=0), BBB, FOLD),
1286                (datetime(2012, 11, 4, 1, 15, 45, fold=1), AAA, FOLD),
1287                (datetime(2012, 11, 4, 2, 15, 45), AAA, NORMAL),
1288            )
1289
1290        cls.test_cases = cases
1291
1292
1293class CTZStrTest(TZStrTest):
1294    module = c_zoneinfo
1295
1296
1297class ZoneInfoCacheTest(TzPathUserMixin, ZoneInfoTestBase):
1298    module = py_zoneinfo
1299
1300    def setUp(self):
1301        self.klass.clear_cache()
1302        super().setUp()
1303
1304    @property
1305    def zoneinfo_data(self):
1306        return ZONEINFO_DATA
1307
1308    @property
1309    def tzpath(self):
1310        return [self.zoneinfo_data.tzpath]
1311
1312    def test_ephemeral_zones(self):
1313        self.assertIs(
1314            self.klass("America/Los_Angeles"), self.klass("America/Los_Angeles")
1315        )
1316
1317    def test_strong_refs(self):
1318        tz0 = self.klass("Australia/Sydney")
1319        tz1 = self.klass("Australia/Sydney")
1320
1321        self.assertIs(tz0, tz1)
1322
1323    def test_no_cache(self):
1324
1325        tz0 = self.klass("Europe/Lisbon")
1326        tz1 = self.klass.no_cache("Europe/Lisbon")
1327
1328        self.assertIsNot(tz0, tz1)
1329
1330    def test_cache_reset_tzpath(self):
1331        """Test that the cache persists when tzpath has been changed.
1332
1333        The PEP specifies that as long as a reference exists to one zone
1334        with a given key, the primary constructor must continue to return
1335        the same object.
1336        """
1337        zi0 = self.klass("America/Los_Angeles")
1338        with self.tzpath_context([]):
1339            zi1 = self.klass("America/Los_Angeles")
1340
1341        self.assertIs(zi0, zi1)
1342
1343    def test_clear_cache_explicit_none(self):
1344        la0 = self.klass("America/Los_Angeles")
1345        self.klass.clear_cache(only_keys=None)
1346        la1 = self.klass("America/Los_Angeles")
1347
1348        self.assertIsNot(la0, la1)
1349
1350    def test_clear_cache_one_key(self):
1351        """Tests that you can clear a single key from the cache."""
1352        la0 = self.klass("America/Los_Angeles")
1353        dub0 = self.klass("Europe/Dublin")
1354
1355        self.klass.clear_cache(only_keys=["America/Los_Angeles"])
1356
1357        la1 = self.klass("America/Los_Angeles")
1358        dub1 = self.klass("Europe/Dublin")
1359
1360        self.assertIsNot(la0, la1)
1361        self.assertIs(dub0, dub1)
1362
1363    def test_clear_cache_two_keys(self):
1364        la0 = self.klass("America/Los_Angeles")
1365        dub0 = self.klass("Europe/Dublin")
1366        tok0 = self.klass("Asia/Tokyo")
1367
1368        self.klass.clear_cache(
1369            only_keys=["America/Los_Angeles", "Europe/Dublin"]
1370        )
1371
1372        la1 = self.klass("America/Los_Angeles")
1373        dub1 = self.klass("Europe/Dublin")
1374        tok1 = self.klass("Asia/Tokyo")
1375
1376        self.assertIsNot(la0, la1)
1377        self.assertIsNot(dub0, dub1)
1378        self.assertIs(tok0, tok1)
1379
1380
1381class CZoneInfoCacheTest(ZoneInfoCacheTest):
1382    module = c_zoneinfo
1383
1384
1385class ZoneInfoPickleTest(TzPathUserMixin, ZoneInfoTestBase):
1386    module = py_zoneinfo
1387
1388    def setUp(self):
1389        self.klass.clear_cache()
1390
1391        with contextlib.ExitStack() as stack:
1392            stack.enter_context(test_support.set_zoneinfo_module(self.module))
1393            self.addCleanup(stack.pop_all().close)
1394
1395        super().setUp()
1396
1397    @property
1398    def zoneinfo_data(self):
1399        return ZONEINFO_DATA
1400
1401    @property
1402    def tzpath(self):
1403        return [self.zoneinfo_data.tzpath]
1404
1405    def test_cache_hit(self):
1406        for proto in range(pickle.HIGHEST_PROTOCOL + 1):
1407            with self.subTest(proto=proto):
1408                zi_in = self.klass("Europe/Dublin")
1409                pkl = pickle.dumps(zi_in, protocol=proto)
1410                zi_rt = pickle.loads(pkl)
1411
1412                with self.subTest(test="Is non-pickled ZoneInfo"):
1413                    self.assertIs(zi_in, zi_rt)
1414
1415                zi_rt2 = pickle.loads(pkl)
1416                with self.subTest(test="Is unpickled ZoneInfo"):
1417                    self.assertIs(zi_rt, zi_rt2)
1418
1419    def test_cache_miss(self):
1420        for proto in range(pickle.HIGHEST_PROTOCOL + 1):
1421            with self.subTest(proto=proto):
1422                zi_in = self.klass("Europe/Dublin")
1423                pkl = pickle.dumps(zi_in, protocol=proto)
1424
1425                del zi_in
1426                self.klass.clear_cache()  # Induce a cache miss
1427                zi_rt = pickle.loads(pkl)
1428                zi_rt2 = pickle.loads(pkl)
1429
1430                self.assertIs(zi_rt, zi_rt2)
1431
1432    def test_no_cache(self):
1433        for proto in range(pickle.HIGHEST_PROTOCOL + 1):
1434            with self.subTest(proto=proto):
1435                zi_no_cache = self.klass.no_cache("Europe/Dublin")
1436
1437                pkl = pickle.dumps(zi_no_cache, protocol=proto)
1438                zi_rt = pickle.loads(pkl)
1439
1440                with self.subTest(test="Not the pickled object"):
1441                    self.assertIsNot(zi_rt, zi_no_cache)
1442
1443                zi_rt2 = pickle.loads(pkl)
1444                with self.subTest(test="Not a second unpickled object"):
1445                    self.assertIsNot(zi_rt, zi_rt2)
1446
1447                zi_cache = self.klass("Europe/Dublin")
1448                with self.subTest(test="Not a cached object"):
1449                    self.assertIsNot(zi_rt, zi_cache)
1450
1451    def test_from_file(self):
1452        key = "Europe/Dublin"
1453        with open(self.zoneinfo_data.path_from_key(key), "rb") as f:
1454            zi_nokey = self.klass.from_file(f)
1455
1456            f.seek(0)
1457            zi_key = self.klass.from_file(f, key=key)
1458
1459        test_cases = [
1460            (zi_key, "ZoneInfo with key"),
1461            (zi_nokey, "ZoneInfo without key"),
1462        ]
1463
1464        for zi, test_name in test_cases:
1465            for proto in range(pickle.HIGHEST_PROTOCOL + 1):
1466                with self.subTest(test_name=test_name, proto=proto):
1467                    with self.assertRaises(pickle.PicklingError):
1468                        pickle.dumps(zi, protocol=proto)
1469
1470    def test_pickle_after_from_file(self):
1471        # This may be a bit of paranoia, but this test is to ensure that no
1472        # global state is maintained in order to handle the pickle cache and
1473        # from_file behavior, and that it is possible to interweave the
1474        # constructors of each of these and pickling/unpickling without issues.
1475        for proto in range(pickle.HIGHEST_PROTOCOL + 1):
1476            with self.subTest(proto=proto):
1477                key = "Europe/Dublin"
1478                zi = self.klass(key)
1479
1480                pkl_0 = pickle.dumps(zi, protocol=proto)
1481                zi_rt_0 = pickle.loads(pkl_0)
1482                self.assertIs(zi, zi_rt_0)
1483
1484                with open(self.zoneinfo_data.path_from_key(key), "rb") as f:
1485                    zi_ff = self.klass.from_file(f, key=key)
1486
1487                pkl_1 = pickle.dumps(zi, protocol=proto)
1488                zi_rt_1 = pickle.loads(pkl_1)
1489                self.assertIs(zi, zi_rt_1)
1490
1491                with self.assertRaises(pickle.PicklingError):
1492                    pickle.dumps(zi_ff, protocol=proto)
1493
1494                pkl_2 = pickle.dumps(zi, protocol=proto)
1495                zi_rt_2 = pickle.loads(pkl_2)
1496                self.assertIs(zi, zi_rt_2)
1497
1498
1499class CZoneInfoPickleTest(ZoneInfoPickleTest):
1500    module = c_zoneinfo
1501
1502
1503class CallingConventionTest(ZoneInfoTestBase):
1504    """Tests for functions with restricted calling conventions."""
1505
1506    module = py_zoneinfo
1507
1508    @property
1509    def zoneinfo_data(self):
1510        return ZONEINFO_DATA
1511
1512    def test_from_file(self):
1513        with open(self.zoneinfo_data.path_from_key("UTC"), "rb") as f:
1514            with self.assertRaises(TypeError):
1515                self.klass.from_file(fobj=f)
1516
1517    def test_clear_cache(self):
1518        with self.assertRaises(TypeError):
1519            self.klass.clear_cache(["UTC"])
1520
1521
1522class CCallingConventionTest(CallingConventionTest):
1523    module = c_zoneinfo
1524
1525
1526class TzPathTest(TzPathUserMixin, ZoneInfoTestBase):
1527    module = py_zoneinfo
1528
1529    @staticmethod
1530    @contextlib.contextmanager
1531    def python_tzpath_context(value):
1532        path_var = "PYTHONTZPATH"
1533        try:
1534            with OS_ENV_LOCK:
1535                old_env = os.environ.get(path_var, None)
1536                os.environ[path_var] = value
1537                yield
1538        finally:
1539            if old_env is None:
1540                del os.environ[path_var]
1541            else:
1542                os.environ[path_var] = old_env  # pragma: nocover
1543
1544    def test_env_variable(self):
1545        """Tests that the environment variable works with reset_tzpath."""
1546        new_paths = [
1547            ("", []),
1548            ("/etc/zoneinfo", ["/etc/zoneinfo"]),
1549            (f"/a/b/c{os.pathsep}/d/e/f", ["/a/b/c", "/d/e/f"]),
1550        ]
1551
1552        for new_path_var, expected_result in new_paths:
1553            with self.python_tzpath_context(new_path_var):
1554                with self.subTest(tzpath=new_path_var):
1555                    self.module.reset_tzpath()
1556                    tzpath = self.module.TZPATH
1557                    self.assertSequenceEqual(tzpath, expected_result)
1558
1559    def test_env_variable_relative_paths(self):
1560        test_cases = [
1561            [("path/to/somewhere",), ()],
1562            [
1563                ("/usr/share/zoneinfo", "path/to/somewhere",),
1564                ("/usr/share/zoneinfo",),
1565            ],
1566            [("../relative/path",), ()],
1567            [
1568                ("/usr/share/zoneinfo", "../relative/path",),
1569                ("/usr/share/zoneinfo",),
1570            ],
1571            [("path/to/somewhere", "../relative/path",), ()],
1572            [
1573                (
1574                    "/usr/share/zoneinfo",
1575                    "path/to/somewhere",
1576                    "../relative/path",
1577                ),
1578                ("/usr/share/zoneinfo",),
1579            ],
1580        ]
1581
1582        for input_paths, expected_paths in test_cases:
1583            path_var = os.pathsep.join(input_paths)
1584            with self.python_tzpath_context(path_var):
1585                with self.subTest("warning", path_var=path_var):
1586                    # Note: Per PEP 615 the warning is implementation-defined
1587                    # behavior, other implementations need not warn.
1588                    with self.assertWarns(self.module.InvalidTZPathWarning):
1589                        self.module.reset_tzpath()
1590
1591                tzpath = self.module.TZPATH
1592                with self.subTest("filtered", path_var=path_var):
1593                    self.assertSequenceEqual(tzpath, expected_paths)
1594
1595    def test_reset_tzpath_kwarg(self):
1596        self.module.reset_tzpath(to=["/a/b/c"])
1597
1598        self.assertSequenceEqual(self.module.TZPATH, ("/a/b/c",))
1599
1600    def test_reset_tzpath_relative_paths(self):
1601        bad_values = [
1602            ("path/to/somewhere",),
1603            ("/usr/share/zoneinfo", "path/to/somewhere",),
1604            ("../relative/path",),
1605            ("/usr/share/zoneinfo", "../relative/path",),
1606            ("path/to/somewhere", "../relative/path",),
1607            ("/usr/share/zoneinfo", "path/to/somewhere", "../relative/path",),
1608        ]
1609        for input_paths in bad_values:
1610            with self.subTest(input_paths=input_paths):
1611                with self.assertRaises(ValueError):
1612                    self.module.reset_tzpath(to=input_paths)
1613
1614    def test_tzpath_type_error(self):
1615        bad_values = [
1616            "/etc/zoneinfo:/usr/share/zoneinfo",
1617            b"/etc/zoneinfo:/usr/share/zoneinfo",
1618            0,
1619        ]
1620
1621        for bad_value in bad_values:
1622            with self.subTest(value=bad_value):
1623                with self.assertRaises(TypeError):
1624                    self.module.reset_tzpath(bad_value)
1625
1626    def test_tzpath_attribute(self):
1627        tzpath_0 = ["/one", "/two"]
1628        tzpath_1 = ["/three"]
1629
1630        with self.tzpath_context(tzpath_0):
1631            query_0 = self.module.TZPATH
1632
1633        with self.tzpath_context(tzpath_1):
1634            query_1 = self.module.TZPATH
1635
1636        self.assertSequenceEqual(tzpath_0, query_0)
1637        self.assertSequenceEqual(tzpath_1, query_1)
1638
1639
1640class CTzPathTest(TzPathTest):
1641    module = c_zoneinfo
1642
1643
1644class TestModule(ZoneInfoTestBase):
1645    module = py_zoneinfo
1646
1647    @property
1648    def zoneinfo_data(self):
1649        return ZONEINFO_DATA
1650
1651    @cached_property
1652    def _UTC_bytes(self):
1653        zone_file = self.zoneinfo_data.path_from_key("UTC")
1654        with open(zone_file, "rb") as f:
1655            return f.read()
1656
1657    def touch_zone(self, key, tz_root):
1658        """Creates a valid TZif file at key under the zoneinfo root tz_root.
1659
1660        tz_root must exist, but all folders below that will be created.
1661        """
1662        if not os.path.exists(tz_root):
1663            raise FileNotFoundError(f"{tz_root} does not exist.")
1664
1665        root_dir, *tail = key.rsplit("/", 1)
1666        if tail:  # If there's no tail, then the first component isn't a dir
1667            os.makedirs(os.path.join(tz_root, root_dir), exist_ok=True)
1668
1669        zonefile_path = os.path.join(tz_root, key)
1670        with open(zonefile_path, "wb") as f:
1671            f.write(self._UTC_bytes)
1672
1673    def test_getattr_error(self):
1674        with self.assertRaises(AttributeError):
1675            self.module.NOATTRIBUTE
1676
1677    def test_dir_contains_all(self):
1678        """dir(self.module) should at least contain everything in __all__."""
1679        module_all_set = set(self.module.__all__)
1680        module_dir_set = set(dir(self.module))
1681
1682        difference = module_all_set - module_dir_set
1683
1684        self.assertFalse(difference)
1685
1686    def test_dir_unique(self):
1687        """Test that there are no duplicates in dir(self.module)"""
1688        module_dir = dir(self.module)
1689        module_unique = set(module_dir)
1690
1691        self.assertCountEqual(module_dir, module_unique)
1692
1693    def test_available_timezones(self):
1694        with self.tzpath_context([self.zoneinfo_data.tzpath]):
1695            self.assertTrue(self.zoneinfo_data.keys)  # Sanity check
1696
1697            available_keys = self.module.available_timezones()
1698            zoneinfo_keys = set(self.zoneinfo_data.keys)
1699
1700            # If tzdata is not present, zoneinfo_keys == available_keys,
1701            # otherwise it should be a subset.
1702            union = zoneinfo_keys & available_keys
1703            self.assertEqual(zoneinfo_keys, union)
1704
1705    def test_available_timezones_weirdzone(self):
1706        with tempfile.TemporaryDirectory() as td:
1707            # Make a fictional zone at "Mars/Olympus_Mons"
1708            self.touch_zone("Mars/Olympus_Mons", td)
1709
1710            with self.tzpath_context([td]):
1711                available_keys = self.module.available_timezones()
1712                self.assertIn("Mars/Olympus_Mons", available_keys)
1713
1714    def test_folder_exclusions(self):
1715        expected = {
1716            "America/Los_Angeles",
1717            "America/Santiago",
1718            "America/Indiana/Indianapolis",
1719            "UTC",
1720            "Europe/Paris",
1721            "Europe/London",
1722            "Asia/Tokyo",
1723            "Australia/Sydney",
1724        }
1725
1726        base_tree = list(expected)
1727        posix_tree = [f"posix/{x}" for x in base_tree]
1728        right_tree = [f"right/{x}" for x in base_tree]
1729
1730        cases = [
1731            ("base_tree", base_tree),
1732            ("base_and_posix", base_tree + posix_tree),
1733            ("base_and_right", base_tree + right_tree),
1734            ("all_trees", base_tree + right_tree + posix_tree),
1735        ]
1736
1737        with tempfile.TemporaryDirectory() as td:
1738            for case_name, tree in cases:
1739                tz_root = os.path.join(td, case_name)
1740                os.mkdir(tz_root)
1741
1742                for key in tree:
1743                    self.touch_zone(key, tz_root)
1744
1745                with self.tzpath_context([tz_root]):
1746                    with self.subTest(case_name):
1747                        actual = self.module.available_timezones()
1748                        self.assertEqual(actual, expected)
1749
1750    def test_exclude_posixrules(self):
1751        expected = {
1752            "America/New_York",
1753            "Europe/London",
1754        }
1755
1756        tree = list(expected) + ["posixrules"]
1757
1758        with tempfile.TemporaryDirectory() as td:
1759            for key in tree:
1760                self.touch_zone(key, td)
1761
1762            with self.tzpath_context([td]):
1763                actual = self.module.available_timezones()
1764                self.assertEqual(actual, expected)
1765
1766
1767class CTestModule(TestModule):
1768    module = c_zoneinfo
1769
1770
1771class ExtensionBuiltTest(unittest.TestCase):
1772    """Smoke test to ensure that the C and Python extensions are both tested.
1773
1774    Because the intention is for the Python and C versions of ZoneInfo to
1775    behave identically, these tests necessarily rely on implementation details,
1776    so the tests may need to be adjusted if the implementations change. Do not
1777    rely on these tests as an indication of stable properties of these classes.
1778    """
1779
1780    def test_cache_location(self):
1781        # The pure Python version stores caches on attributes, but the C
1782        # extension stores them in C globals (at least for now)
1783        self.assertFalse(hasattr(c_zoneinfo.ZoneInfo, "_weak_cache"))
1784        self.assertTrue(hasattr(py_zoneinfo.ZoneInfo, "_weak_cache"))
1785
1786    def test_gc_tracked(self):
1787        # The pure Python version is tracked by the GC but (for now) the C
1788        # version is not.
1789        import gc
1790
1791        self.assertTrue(gc.is_tracked(py_zoneinfo.ZoneInfo))
1792        self.assertFalse(gc.is_tracked(c_zoneinfo.ZoneInfo))
1793
1794
1795@dataclasses.dataclass(frozen=True)
1796class ZoneOffset:
1797    tzname: str
1798    utcoffset: timedelta
1799    dst: timedelta = ZERO
1800
1801
1802@dataclasses.dataclass(frozen=True)
1803class ZoneTransition:
1804    transition: datetime
1805    offset_before: ZoneOffset
1806    offset_after: ZoneOffset
1807
1808    @property
1809    def transition_utc(self):
1810        return (self.transition - self.offset_before.utcoffset).replace(
1811            tzinfo=timezone.utc
1812        )
1813
1814    @property
1815    def fold(self):
1816        """Whether this introduces a fold"""
1817        return self.offset_before.utcoffset > self.offset_after.utcoffset
1818
1819    @property
1820    def gap(self):
1821        """Whether this introduces a gap"""
1822        return self.offset_before.utcoffset < self.offset_after.utcoffset
1823
1824    @property
1825    def delta(self):
1826        return self.offset_after.utcoffset - self.offset_before.utcoffset
1827
1828    @property
1829    def anomaly_start(self):
1830        if self.fold:
1831            return self.transition + self.delta
1832        else:
1833            return self.transition
1834
1835    @property
1836    def anomaly_end(self):
1837        if not self.fold:
1838            return self.transition + self.delta
1839        else:
1840            return self.transition
1841
1842
1843class ZoneInfoData:
1844    def __init__(self, source_json, tzpath, v1=False):
1845        self.tzpath = pathlib.Path(tzpath)
1846        self.keys = []
1847        self.v1 = v1
1848        self._populate_tzpath(source_json)
1849
1850    def path_from_key(self, key):
1851        return self.tzpath / key
1852
1853    def _populate_tzpath(self, source_json):
1854        with open(source_json, "rb") as f:
1855            zoneinfo_dict = json.load(f)
1856
1857        zoneinfo_data = zoneinfo_dict["data"]
1858
1859        for key, value in zoneinfo_data.items():
1860            self.keys.append(key)
1861            raw_data = self._decode_text(value)
1862
1863            if self.v1:
1864                data = self._convert_to_v1(raw_data)
1865            else:
1866                data = raw_data
1867
1868            destination = self.path_from_key(key)
1869            destination.parent.mkdir(exist_ok=True, parents=True)
1870            with open(destination, "wb") as f:
1871                f.write(data)
1872
1873    def _decode_text(self, contents):
1874        raw_data = b"".join(map(str.encode, contents))
1875        decoded = base64.b85decode(raw_data)
1876
1877        return lzma.decompress(decoded)
1878
1879    def _convert_to_v1(self, contents):
1880        assert contents[0:4] == b"TZif", "Invalid TZif data found!"
1881        version = int(contents[4:5])
1882
1883        header_start = 4 + 16
1884        header_end = header_start + 24  # 6l == 24 bytes
1885        assert version >= 2, "Version 1 file found: no conversion necessary"
1886        isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt = struct.unpack(
1887            ">6l", contents[header_start:header_end]
1888        )
1889
1890        file_size = (
1891            timecnt * 5
1892            + typecnt * 6
1893            + charcnt
1894            + leapcnt * 8
1895            + isstdcnt
1896            + isutcnt
1897        )
1898        file_size += header_end
1899        out = b"TZif" + b"\x00" + contents[5:file_size]
1900
1901        assert (
1902            contents[file_size : (file_size + 4)] == b"TZif"
1903        ), "Version 2 file not truncated at Version 2 header"
1904
1905        return out
1906
1907
1908class ZoneDumpData:
1909    @classmethod
1910    def transition_keys(cls):
1911        return cls._get_zonedump().keys()
1912
1913    @classmethod
1914    def load_transition_examples(cls, key):
1915        return cls._get_zonedump()[key]
1916
1917    @classmethod
1918    def fixed_offset_zones(cls):
1919        if not cls._FIXED_OFFSET_ZONES:
1920            cls._populate_fixed_offsets()
1921
1922        return cls._FIXED_OFFSET_ZONES.items()
1923
1924    @classmethod
1925    def _get_zonedump(cls):
1926        if not cls._ZONEDUMP_DATA:
1927            cls._populate_zonedump_data()
1928        return cls._ZONEDUMP_DATA
1929
1930    @classmethod
1931    def _populate_fixed_offsets(cls):
1932        cls._FIXED_OFFSET_ZONES = {
1933            "UTC": ZoneOffset("UTC", ZERO, ZERO),
1934        }
1935
1936    @classmethod
1937    def _populate_zonedump_data(cls):
1938        def _Africa_Abidjan():
1939            LMT = ZoneOffset("LMT", timedelta(seconds=-968))
1940            GMT = ZoneOffset("GMT", ZERO)
1941
1942            return [
1943                ZoneTransition(datetime(1912, 1, 1), LMT, GMT),
1944            ]
1945
1946        def _Africa_Casablanca():
1947            P00_s = ZoneOffset("+00", ZERO, ZERO)
1948            P01_d = ZoneOffset("+01", ONE_H, ONE_H)
1949            P00_d = ZoneOffset("+00", ZERO, -ONE_H)
1950            P01_s = ZoneOffset("+01", ONE_H, ZERO)
1951
1952            return [
1953                # Morocco sometimes pauses DST during Ramadan
1954                ZoneTransition(datetime(2018, 3, 25, 2), P00_s, P01_d),
1955                ZoneTransition(datetime(2018, 5, 13, 3), P01_d, P00_s),
1956                ZoneTransition(datetime(2018, 6, 17, 2), P00_s, P01_d),
1957                # On October 28th Morocco set standard time to +01,
1958                # with negative DST only during Ramadan
1959                ZoneTransition(datetime(2018, 10, 28, 3), P01_d, P01_s),
1960                ZoneTransition(datetime(2019, 5, 5, 3), P01_s, P00_d),
1961                ZoneTransition(datetime(2019, 6, 9, 2), P00_d, P01_s),
1962            ]
1963
1964        def _America_Los_Angeles():
1965            LMT = ZoneOffset("LMT", timedelta(seconds=-28378), ZERO)
1966            PST = ZoneOffset("PST", timedelta(hours=-8), ZERO)
1967            PDT = ZoneOffset("PDT", timedelta(hours=-7), ONE_H)
1968            PWT = ZoneOffset("PWT", timedelta(hours=-7), ONE_H)
1969            PPT = ZoneOffset("PPT", timedelta(hours=-7), ONE_H)
1970
1971            return [
1972                ZoneTransition(datetime(1883, 11, 18, 12, 7, 2), LMT, PST),
1973                ZoneTransition(datetime(1918, 3, 31, 2), PST, PDT),
1974                ZoneTransition(datetime(1918, 3, 31, 2), PST, PDT),
1975                ZoneTransition(datetime(1918, 10, 27, 2), PDT, PST),
1976                # Transition to Pacific War Time
1977                ZoneTransition(datetime(1942, 2, 9, 2), PST, PWT),
1978                # Transition from Pacific War Time to Pacific Peace Time
1979                ZoneTransition(datetime(1945, 8, 14, 16), PWT, PPT),
1980                ZoneTransition(datetime(1945, 9, 30, 2), PPT, PST),
1981                ZoneTransition(datetime(2015, 3, 8, 2), PST, PDT),
1982                ZoneTransition(datetime(2015, 11, 1, 2), PDT, PST),
1983                # After 2038: Rules continue indefinitely
1984                ZoneTransition(datetime(2450, 3, 13, 2), PST, PDT),
1985                ZoneTransition(datetime(2450, 11, 6, 2), PDT, PST),
1986            ]
1987
1988        def _America_Santiago():
1989            LMT = ZoneOffset("LMT", timedelta(seconds=-16966), ZERO)
1990            SMT = ZoneOffset("SMT", timedelta(seconds=-16966), ZERO)
1991            N05 = ZoneOffset("-05", timedelta(seconds=-18000), ZERO)
1992            N04 = ZoneOffset("-04", timedelta(seconds=-14400), ZERO)
1993            N03 = ZoneOffset("-03", timedelta(seconds=-10800), ONE_H)
1994
1995            return [
1996                ZoneTransition(datetime(1890, 1, 1), LMT, SMT),
1997                ZoneTransition(datetime(1910, 1, 10), SMT, N05),
1998                ZoneTransition(datetime(1916, 7, 1), N05, SMT),
1999                ZoneTransition(datetime(2008, 3, 30), N03, N04),
2000                ZoneTransition(datetime(2008, 10, 12), N04, N03),
2001                ZoneTransition(datetime(2040, 4, 8), N03, N04),
2002                ZoneTransition(datetime(2040, 9, 2), N04, N03),
2003            ]
2004
2005        def _Asia_Tokyo():
2006            JST = ZoneOffset("JST", timedelta(seconds=32400), ZERO)
2007            JDT = ZoneOffset("JDT", timedelta(seconds=36000), ONE_H)
2008
2009            # Japan had DST from 1948 to 1951, and it was unusual in that
2010            # the transition from DST to STD occurred at 25:00, and is
2011            # denominated as such in the time zone database
2012            return [
2013                ZoneTransition(datetime(1948, 5, 2), JST, JDT),
2014                ZoneTransition(datetime(1948, 9, 12, 1), JDT, JST),
2015                ZoneTransition(datetime(1951, 9, 9, 1), JDT, JST),
2016            ]
2017
2018        def _Australia_Sydney():
2019            LMT = ZoneOffset("LMT", timedelta(seconds=36292), ZERO)
2020            AEST = ZoneOffset("AEST", timedelta(seconds=36000), ZERO)
2021            AEDT = ZoneOffset("AEDT", timedelta(seconds=39600), ONE_H)
2022
2023            return [
2024                ZoneTransition(datetime(1895, 2, 1), LMT, AEST),
2025                ZoneTransition(datetime(1917, 1, 1, 0, 1), AEST, AEDT),
2026                ZoneTransition(datetime(1917, 3, 25, 2), AEDT, AEST),
2027                ZoneTransition(datetime(2012, 4, 1, 3), AEDT, AEST),
2028                ZoneTransition(datetime(2012, 10, 7, 2), AEST, AEDT),
2029                ZoneTransition(datetime(2040, 4, 1, 3), AEDT, AEST),
2030                ZoneTransition(datetime(2040, 10, 7, 2), AEST, AEDT),
2031            ]
2032
2033        def _Europe_Dublin():
2034            LMT = ZoneOffset("LMT", timedelta(seconds=-1500), ZERO)
2035            DMT = ZoneOffset("DMT", timedelta(seconds=-1521), ZERO)
2036            IST_0 = ZoneOffset("IST", timedelta(seconds=2079), ONE_H)
2037            GMT_0 = ZoneOffset("GMT", ZERO, ZERO)
2038            BST = ZoneOffset("BST", ONE_H, ONE_H)
2039            GMT_1 = ZoneOffset("GMT", ZERO, -ONE_H)
2040            IST_1 = ZoneOffset("IST", ONE_H, ZERO)
2041
2042            return [
2043                ZoneTransition(datetime(1880, 8, 2, 0), LMT, DMT),
2044                ZoneTransition(datetime(1916, 5, 21, 2), DMT, IST_0),
2045                ZoneTransition(datetime(1916, 10, 1, 3), IST_0, GMT_0),
2046                ZoneTransition(datetime(1917, 4, 8, 2), GMT_0, BST),
2047                ZoneTransition(datetime(2016, 3, 27, 1), GMT_1, IST_1),
2048                ZoneTransition(datetime(2016, 10, 30, 2), IST_1, GMT_1),
2049                ZoneTransition(datetime(2487, 3, 30, 1), GMT_1, IST_1),
2050                ZoneTransition(datetime(2487, 10, 26, 2), IST_1, GMT_1),
2051            ]
2052
2053        def _Europe_Lisbon():
2054            WET = ZoneOffset("WET", ZERO, ZERO)
2055            WEST = ZoneOffset("WEST", ONE_H, ONE_H)
2056            CET = ZoneOffset("CET", ONE_H, ZERO)
2057            CEST = ZoneOffset("CEST", timedelta(seconds=7200), ONE_H)
2058
2059            return [
2060                ZoneTransition(datetime(1992, 3, 29, 1), WET, WEST),
2061                ZoneTransition(datetime(1992, 9, 27, 2), WEST, CET),
2062                ZoneTransition(datetime(1993, 3, 28, 2), CET, CEST),
2063                ZoneTransition(datetime(1993, 9, 26, 3), CEST, CET),
2064                ZoneTransition(datetime(1996, 3, 31, 2), CET, WEST),
2065                ZoneTransition(datetime(1996, 10, 27, 2), WEST, WET),
2066            ]
2067
2068        def _Europe_London():
2069            LMT = ZoneOffset("LMT", timedelta(seconds=-75), ZERO)
2070            GMT = ZoneOffset("GMT", ZERO, ZERO)
2071            BST = ZoneOffset("BST", ONE_H, ONE_H)
2072
2073            return [
2074                ZoneTransition(datetime(1847, 12, 1), LMT, GMT),
2075                ZoneTransition(datetime(2005, 3, 27, 1), GMT, BST),
2076                ZoneTransition(datetime(2005, 10, 30, 2), BST, GMT),
2077                ZoneTransition(datetime(2043, 3, 29, 1), GMT, BST),
2078                ZoneTransition(datetime(2043, 10, 25, 2), BST, GMT),
2079            ]
2080
2081        def _Pacific_Kiritimati():
2082            LMT = ZoneOffset("LMT", timedelta(seconds=-37760), ZERO)
2083            N1040 = ZoneOffset("-1040", timedelta(seconds=-38400), ZERO)
2084            N10 = ZoneOffset("-10", timedelta(seconds=-36000), ZERO)
2085            P14 = ZoneOffset("+14", timedelta(seconds=50400), ZERO)
2086
2087            # This is literally every transition in Christmas Island history
2088            return [
2089                ZoneTransition(datetime(1901, 1, 1), LMT, N1040),
2090                ZoneTransition(datetime(1979, 10, 1), N1040, N10),
2091                # They skipped December 31, 1994
2092                ZoneTransition(datetime(1994, 12, 31), N10, P14),
2093            ]
2094
2095        cls._ZONEDUMP_DATA = {
2096            "Africa/Abidjan": _Africa_Abidjan(),
2097            "Africa/Casablanca": _Africa_Casablanca(),
2098            "America/Los_Angeles": _America_Los_Angeles(),
2099            "America/Santiago": _America_Santiago(),
2100            "Australia/Sydney": _Australia_Sydney(),
2101            "Asia/Tokyo": _Asia_Tokyo(),
2102            "Europe/Dublin": _Europe_Dublin(),
2103            "Europe/Lisbon": _Europe_Lisbon(),
2104            "Europe/London": _Europe_London(),
2105            "Pacific/Kiritimati": _Pacific_Kiritimati(),
2106        }
2107
2108    _ZONEDUMP_DATA = None
2109    _FIXED_OFFSET_ZONES = None
2110