• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1import bisect
2import calendar
3import collections
4import functools
5import re
6import weakref
7from datetime import datetime, timedelta, tzinfo
8
9from . import _common, _tzpath
10
11EPOCH = datetime(1970, 1, 1)
12EPOCHORDINAL = datetime(1970, 1, 1).toordinal()
13
14# It is relatively expensive to construct new timedelta objects, and in most
15# cases we're looking at the same deltas, like integer numbers of hours, etc.
16# To improve speed and memory use, we'll keep a dictionary with references
17# to the ones we've already used so far.
18#
19# Loading every time zone in the 2020a version of the time zone database
20# requires 447 timedeltas, which requires approximately the amount of space
21# that ZoneInfo("America/New_York") with 236 transitions takes up, so we will
22# set the cache size to 512 so that in the common case we always get cache
23# hits, but specifically crafted ZoneInfo objects don't leak arbitrary amounts
24# of memory.
25@functools.lru_cache(maxsize=512)
26def _load_timedelta(seconds):
27    return timedelta(seconds=seconds)
28
29
30class ZoneInfo(tzinfo):
31    _strong_cache_size = 8
32    _strong_cache = collections.OrderedDict()
33    _weak_cache = weakref.WeakValueDictionary()
34    __module__ = "zoneinfo"
35
36    def __init_subclass__(cls):
37        cls._strong_cache = collections.OrderedDict()
38        cls._weak_cache = weakref.WeakValueDictionary()
39
40    def __new__(cls, key):
41        instance = cls._weak_cache.get(key, None)
42        if instance is None:
43            instance = cls._weak_cache.setdefault(key, cls._new_instance(key))
44            instance._from_cache = True
45
46        # Update the "strong" cache
47        cls._strong_cache[key] = cls._strong_cache.pop(key, instance)
48
49        if len(cls._strong_cache) > cls._strong_cache_size:
50            cls._strong_cache.popitem(last=False)
51
52        return instance
53
54    @classmethod
55    def no_cache(cls, key):
56        obj = cls._new_instance(key)
57        obj._from_cache = False
58
59        return obj
60
61    @classmethod
62    def _new_instance(cls, key):
63        obj = super().__new__(cls)
64        obj._key = key
65        obj._file_path = obj._find_tzfile(key)
66
67        if obj._file_path is not None:
68            file_obj = open(obj._file_path, "rb")
69        else:
70            file_obj = _common.load_tzdata(key)
71
72        with file_obj as f:
73            obj._load_file(f)
74
75        return obj
76
77    @classmethod
78    def from_file(cls, fobj, /, key=None):
79        obj = super().__new__(cls)
80        obj._key = key
81        obj._file_path = None
82        obj._load_file(fobj)
83        obj._file_repr = repr(fobj)
84
85        # Disable pickling for objects created from files
86        obj.__reduce__ = obj._file_reduce
87
88        return obj
89
90    @classmethod
91    def clear_cache(cls, *, only_keys=None):
92        if only_keys is not None:
93            for key in only_keys:
94                cls._weak_cache.pop(key, None)
95                cls._strong_cache.pop(key, None)
96
97        else:
98            cls._weak_cache.clear()
99            cls._strong_cache.clear()
100
101    @property
102    def key(self):
103        return self._key
104
105    def utcoffset(self, dt):
106        return self._find_trans(dt).utcoff
107
108    def dst(self, dt):
109        return self._find_trans(dt).dstoff
110
111    def tzname(self, dt):
112        return self._find_trans(dt).tzname
113
114    def fromutc(self, dt):
115        """Convert from datetime in UTC to datetime in local time"""
116
117        if not isinstance(dt, datetime):
118            raise TypeError("fromutc() requires a datetime argument")
119        if dt.tzinfo is not self:
120            raise ValueError("dt.tzinfo is not self")
121
122        timestamp = self._get_local_timestamp(dt)
123        num_trans = len(self._trans_utc)
124
125        if num_trans >= 1 and timestamp < self._trans_utc[0]:
126            tti = self._tti_before
127            fold = 0
128        elif (
129            num_trans == 0 or timestamp > self._trans_utc[-1]
130        ) and not isinstance(self._tz_after, _ttinfo):
131            tti, fold = self._tz_after.get_trans_info_fromutc(
132                timestamp, dt.year
133            )
134        elif num_trans == 0:
135            tti = self._tz_after
136            fold = 0
137        else:
138            idx = bisect.bisect_right(self._trans_utc, timestamp)
139
140            if num_trans > 1 and timestamp >= self._trans_utc[1]:
141                tti_prev, tti = self._ttinfos[idx - 2 : idx]
142            elif timestamp > self._trans_utc[-1]:
143                tti_prev = self._ttinfos[-1]
144                tti = self._tz_after
145            else:
146                tti_prev = self._tti_before
147                tti = self._ttinfos[0]
148
149            # Detect fold
150            shift = tti_prev.utcoff - tti.utcoff
151            fold = shift.total_seconds() > timestamp - self._trans_utc[idx - 1]
152        dt += tti.utcoff
153        if fold:
154            return dt.replace(fold=1)
155        else:
156            return dt
157
158    def _find_trans(self, dt):
159        if dt is None:
160            if self._fixed_offset:
161                return self._tz_after
162            else:
163                return _NO_TTINFO
164
165        ts = self._get_local_timestamp(dt)
166
167        lt = self._trans_local[dt.fold]
168
169        num_trans = len(lt)
170
171        if num_trans and ts < lt[0]:
172            return self._tti_before
173        elif not num_trans or ts > lt[-1]:
174            if isinstance(self._tz_after, _TZStr):
175                return self._tz_after.get_trans_info(ts, dt.year, dt.fold)
176            else:
177                return self._tz_after
178        else:
179            # idx is the transition that occurs after this timestamp, so we
180            # subtract off 1 to get the current ttinfo
181            idx = bisect.bisect_right(lt, ts) - 1
182            assert idx >= 0
183            return self._ttinfos[idx]
184
185    def _get_local_timestamp(self, dt):
186        return (
187            (dt.toordinal() - EPOCHORDINAL) * 86400
188            + dt.hour * 3600
189            + dt.minute * 60
190            + dt.second
191        )
192
193    def __str__(self):
194        if self._key is not None:
195            return f"{self._key}"
196        else:
197            return repr(self)
198
199    def __repr__(self):
200        if self._key is not None:
201            return f"{self.__class__.__name__}(key={self._key!r})"
202        else:
203            return f"{self.__class__.__name__}.from_file({self._file_repr})"
204
205    def __reduce__(self):
206        return (self.__class__._unpickle, (self._key, self._from_cache))
207
208    def _file_reduce(self):
209        import pickle
210
211        raise pickle.PicklingError(
212            "Cannot pickle a ZoneInfo file created from a file stream."
213        )
214
215    @classmethod
216    def _unpickle(cls, key, from_cache, /):
217        if from_cache:
218            return cls(key)
219        else:
220            return cls.no_cache(key)
221
222    def _find_tzfile(self, key):
223        return _tzpath.find_tzfile(key)
224
225    def _load_file(self, fobj):
226        # Retrieve all the data as it exists in the zoneinfo file
227        trans_idx, trans_utc, utcoff, isdst, abbr, tz_str = _common.load_data(
228            fobj
229        )
230
231        # Infer the DST offsets (needed for .dst()) from the data
232        dstoff = self._utcoff_to_dstoff(trans_idx, utcoff, isdst)
233
234        # Convert all the transition times (UTC) into "seconds since 1970-01-01 local time"
235        trans_local = self._ts_to_local(trans_idx, trans_utc, utcoff)
236
237        # Construct `_ttinfo` objects for each transition in the file
238        _ttinfo_list = [
239            _ttinfo(
240                _load_timedelta(utcoffset), _load_timedelta(dstoffset), tzname
241            )
242            for utcoffset, dstoffset, tzname in zip(utcoff, dstoff, abbr)
243        ]
244
245        self._trans_utc = trans_utc
246        self._trans_local = trans_local
247        self._ttinfos = [_ttinfo_list[idx] for idx in trans_idx]
248
249        # Find the first non-DST transition
250        for i in range(len(isdst)):
251            if not isdst[i]:
252                self._tti_before = _ttinfo_list[i]
253                break
254        else:
255            if self._ttinfos:
256                self._tti_before = self._ttinfos[0]
257            else:
258                self._tti_before = None
259
260        # Set the "fallback" time zone
261        if tz_str is not None and tz_str != b"":
262            self._tz_after = _parse_tz_str(tz_str.decode())
263        else:
264            if not self._ttinfos and not _ttinfo_list:
265                raise ValueError("No time zone information found.")
266
267            if self._ttinfos:
268                self._tz_after = self._ttinfos[-1]
269            else:
270                self._tz_after = _ttinfo_list[-1]
271
272        # Determine if this is a "fixed offset" zone, meaning that the output
273        # of the utcoffset, dst and tzname functions does not depend on the
274        # specific datetime passed.
275        #
276        # We make three simplifying assumptions here:
277        #
278        # 1. If _tz_after is not a _ttinfo, it has transitions that might
279        #    actually occur (it is possible to construct TZ strings that
280        #    specify STD and DST but no transitions ever occur, such as
281        #    AAA0BBB,0/0,J365/25).
282        # 2. If _ttinfo_list contains more than one _ttinfo object, the objects
283        #    represent different offsets.
284        # 3. _ttinfo_list contains no unused _ttinfos (in which case an
285        #    otherwise fixed-offset zone with extra _ttinfos defined may
286        #    appear to *not* be a fixed offset zone).
287        #
288        # Violations to these assumptions would be fairly exotic, and exotic
289        # zones should almost certainly not be used with datetime.time (the
290        # only thing that would be affected by this).
291        if len(_ttinfo_list) > 1 or not isinstance(self._tz_after, _ttinfo):
292            self._fixed_offset = False
293        elif not _ttinfo_list:
294            self._fixed_offset = True
295        else:
296            self._fixed_offset = _ttinfo_list[0] == self._tz_after
297
298    @staticmethod
299    def _utcoff_to_dstoff(trans_idx, utcoffsets, isdsts):
300        # Now we must transform our ttis and abbrs into `_ttinfo` objects,
301        # but there is an issue: .dst() must return a timedelta with the
302        # difference between utcoffset() and the "standard" offset, but
303        # the "base offset" and "DST offset" are not encoded in the file;
304        # we can infer what they are from the isdst flag, but it is not
305        # sufficient to to just look at the last standard offset, because
306        # occasionally countries will shift both DST offset and base offset.
307
308        typecnt = len(isdsts)
309        dstoffs = [0] * typecnt  # Provisionally assign all to 0.
310        dst_cnt = sum(isdsts)
311        dst_found = 0
312
313        for i in range(1, len(trans_idx)):
314            if dst_cnt == dst_found:
315                break
316
317            idx = trans_idx[i]
318
319            dst = isdsts[idx]
320
321            # We're only going to look at daylight saving time
322            if not dst:
323                continue
324
325            # Skip any offsets that have already been assigned
326            if dstoffs[idx] != 0:
327                continue
328
329            dstoff = 0
330            utcoff = utcoffsets[idx]
331
332            comp_idx = trans_idx[i - 1]
333
334            if not isdsts[comp_idx]:
335                dstoff = utcoff - utcoffsets[comp_idx]
336
337            if not dstoff and idx < (typecnt - 1):
338                comp_idx = trans_idx[i + 1]
339
340                # If the following transition is also DST and we couldn't
341                # find the DST offset by this point, we're going to have to
342                # skip it and hope this transition gets assigned later
343                if isdsts[comp_idx]:
344                    continue
345
346                dstoff = utcoff - utcoffsets[comp_idx]
347
348            if dstoff:
349                dst_found += 1
350                dstoffs[idx] = dstoff
351        else:
352            # If we didn't find a valid value for a given index, we'll end up
353            # with dstoff = 0 for something where `isdst=1`. This is obviously
354            # wrong - one hour will be a much better guess than 0
355            for idx in range(typecnt):
356                if not dstoffs[idx] and isdsts[idx]:
357                    dstoffs[idx] = 3600
358
359        return dstoffs
360
361    @staticmethod
362    def _ts_to_local(trans_idx, trans_list_utc, utcoffsets):
363        """Generate number of seconds since 1970 *in the local time*.
364
365        This is necessary to easily find the transition times in local time"""
366        if not trans_list_utc:
367            return [[], []]
368
369        # Start with the timestamps and modify in-place
370        trans_list_wall = [list(trans_list_utc), list(trans_list_utc)]
371
372        if len(utcoffsets) > 1:
373            offset_0 = utcoffsets[0]
374            offset_1 = utcoffsets[trans_idx[0]]
375            if offset_1 > offset_0:
376                offset_1, offset_0 = offset_0, offset_1
377        else:
378            offset_0 = offset_1 = utcoffsets[0]
379
380        trans_list_wall[0][0] += offset_0
381        trans_list_wall[1][0] += offset_1
382
383        for i in range(1, len(trans_idx)):
384            offset_0 = utcoffsets[trans_idx[i - 1]]
385            offset_1 = utcoffsets[trans_idx[i]]
386
387            if offset_1 > offset_0:
388                offset_1, offset_0 = offset_0, offset_1
389
390            trans_list_wall[0][i] += offset_0
391            trans_list_wall[1][i] += offset_1
392
393        return trans_list_wall
394
395
396class _ttinfo:
397    __slots__ = ["utcoff", "dstoff", "tzname"]
398
399    def __init__(self, utcoff, dstoff, tzname):
400        self.utcoff = utcoff
401        self.dstoff = dstoff
402        self.tzname = tzname
403
404    def __eq__(self, other):
405        return (
406            self.utcoff == other.utcoff
407            and self.dstoff == other.dstoff
408            and self.tzname == other.tzname
409        )
410
411    def __repr__(self):  # pragma: nocover
412        return (
413            f"{self.__class__.__name__}"
414            + f"({self.utcoff}, {self.dstoff}, {self.tzname})"
415        )
416
417
418_NO_TTINFO = _ttinfo(None, None, None)
419
420
421class _TZStr:
422    __slots__ = (
423        "std",
424        "dst",
425        "start",
426        "end",
427        "get_trans_info",
428        "get_trans_info_fromutc",
429        "dst_diff",
430    )
431
432    def __init__(
433        self, std_abbr, std_offset, dst_abbr, dst_offset, start=None, end=None
434    ):
435        self.dst_diff = dst_offset - std_offset
436        std_offset = _load_timedelta(std_offset)
437        self.std = _ttinfo(
438            utcoff=std_offset, dstoff=_load_timedelta(0), tzname=std_abbr
439        )
440
441        self.start = start
442        self.end = end
443
444        dst_offset = _load_timedelta(dst_offset)
445        delta = _load_timedelta(self.dst_diff)
446        self.dst = _ttinfo(utcoff=dst_offset, dstoff=delta, tzname=dst_abbr)
447
448        # These are assertions because the constructor should only be called
449        # by functions that would fail before passing start or end
450        assert start is not None, "No transition start specified"
451        assert end is not None, "No transition end specified"
452
453        self.get_trans_info = self._get_trans_info
454        self.get_trans_info_fromutc = self._get_trans_info_fromutc
455
456    def transitions(self, year):
457        start = self.start.year_to_epoch(year)
458        end = self.end.year_to_epoch(year)
459        return start, end
460
461    def _get_trans_info(self, ts, year, fold):
462        """Get the information about the current transition - tti"""
463        start, end = self.transitions(year)
464
465        # With fold = 0, the period (denominated in local time) with the
466        # smaller offset starts at the end of the gap and ends at the end of
467        # the fold; with fold = 1, it runs from the start of the gap to the
468        # beginning of the fold.
469        #
470        # So in order to determine the DST boundaries we need to know both
471        # the fold and whether DST is positive or negative (rare), and it
472        # turns out that this boils down to fold XOR is_positive.
473        if fold == (self.dst_diff >= 0):
474            end -= self.dst_diff
475        else:
476            start += self.dst_diff
477
478        if start < end:
479            isdst = start <= ts < end
480        else:
481            isdst = not (end <= ts < start)
482
483        return self.dst if isdst else self.std
484
485    def _get_trans_info_fromutc(self, ts, year):
486        start, end = self.transitions(year)
487        start -= self.std.utcoff.total_seconds()
488        end -= self.dst.utcoff.total_seconds()
489
490        if start < end:
491            isdst = start <= ts < end
492        else:
493            isdst = not (end <= ts < start)
494
495        # For positive DST, the ambiguous period is one dst_diff after the end
496        # of DST; for negative DST, the ambiguous period is one dst_diff before
497        # the start of DST.
498        if self.dst_diff > 0:
499            ambig_start = end
500            ambig_end = end + self.dst_diff
501        else:
502            ambig_start = start
503            ambig_end = start - self.dst_diff
504
505        fold = ambig_start <= ts < ambig_end
506
507        return (self.dst if isdst else self.std, fold)
508
509
510def _post_epoch_days_before_year(year):
511    """Get the number of days between 1970-01-01 and YEAR-01-01"""
512    y = year - 1
513    return y * 365 + y // 4 - y // 100 + y // 400 - EPOCHORDINAL
514
515
516class _DayOffset:
517    __slots__ = ["d", "julian", "hour", "minute", "second"]
518
519    def __init__(self, d, julian, hour=2, minute=0, second=0):
520        if not (0 + julian) <= d <= 365:
521            min_day = 0 + julian
522            raise ValueError(f"d must be in [{min_day}, 365], not: {d}")
523
524        self.d = d
525        self.julian = julian
526        self.hour = hour
527        self.minute = minute
528        self.second = second
529
530    def year_to_epoch(self, year):
531        days_before_year = _post_epoch_days_before_year(year)
532
533        d = self.d
534        if self.julian and d >= 59 and calendar.isleap(year):
535            d += 1
536
537        epoch = (days_before_year + d) * 86400
538        epoch += self.hour * 3600 + self.minute * 60 + self.second
539
540        return epoch
541
542
543class _CalendarOffset:
544    __slots__ = ["m", "w", "d", "hour", "minute", "second"]
545
546    _DAYS_BEFORE_MONTH = (
547        -1,
548        0,
549        31,
550        59,
551        90,
552        120,
553        151,
554        181,
555        212,
556        243,
557        273,
558        304,
559        334,
560    )
561
562    def __init__(self, m, w, d, hour=2, minute=0, second=0):
563        if not 0 < m <= 12:
564            raise ValueError("m must be in (0, 12]")
565
566        if not 0 < w <= 5:
567            raise ValueError("w must be in (0, 5]")
568
569        if not 0 <= d <= 6:
570            raise ValueError("d must be in [0, 6]")
571
572        self.m = m
573        self.w = w
574        self.d = d
575        self.hour = hour
576        self.minute = minute
577        self.second = second
578
579    @classmethod
580    def _ymd2ord(cls, year, month, day):
581        return (
582            _post_epoch_days_before_year(year)
583            + cls._DAYS_BEFORE_MONTH[month]
584            + (month > 2 and calendar.isleap(year))
585            + day
586        )
587
588    # TODO: These are not actually epoch dates as they are expressed in local time
589    def year_to_epoch(self, year):
590        """Calculates the datetime of the occurrence from the year"""
591        # We know year and month, we need to convert w, d into day of month
592        #
593        # Week 1 is the first week in which day `d` (where 0 = Sunday) appears.
594        # Week 5 represents the last occurrence of day `d`, so we need to know
595        # the range of the month.
596        first_day, days_in_month = calendar.monthrange(year, self.m)
597
598        # This equation seems magical, so I'll break it down:
599        # 1. calendar says 0 = Monday, POSIX says 0 = Sunday
600        #    so we need first_day + 1 to get 1 = Monday -> 7 = Sunday,
601        #    which is still equivalent because this math is mod 7
602        # 2. Get first day - desired day mod 7: -1 % 7 = 6, so we don't need
603        #    to do anything to adjust negative numbers.
604        # 3. Add 1 because month days are a 1-based index.
605        month_day = (self.d - (first_day + 1)) % 7 + 1
606
607        # Now use a 0-based index version of `w` to calculate the w-th
608        # occurrence of `d`
609        month_day += (self.w - 1) * 7
610
611        # month_day will only be > days_in_month if w was 5, and `w` means
612        # "last occurrence of `d`", so now we just check if we over-shot the
613        # end of the month and if so knock off 1 week.
614        if month_day > days_in_month:
615            month_day -= 7
616
617        ordinal = self._ymd2ord(year, self.m, month_day)
618        epoch = ordinal * 86400
619        epoch += self.hour * 3600 + self.minute * 60 + self.second
620        return epoch
621
622
623def _parse_tz_str(tz_str):
624    # The tz string has the format:
625    #
626    # std[offset[dst[offset],start[/time],end[/time]]]
627    #
628    # std and dst must be 3 or more characters long and must not contain
629    # a leading colon, embedded digits, commas, nor a plus or minus signs;
630    # The spaces between "std" and "offset" are only for display and are
631    # not actually present in the string.
632    #
633    # The format of the offset is ``[+|-]hh[:mm[:ss]]``
634
635    offset_str, *start_end_str = tz_str.split(",", 1)
636
637    # fmt: off
638    parser_re = re.compile(
639        r"(?P<std>[^<0-9:.+-]+|<[a-zA-Z0-9+\-]+>)" +
640        r"((?P<stdoff>[+-]?\d{1,2}(:\d{2}(:\d{2})?)?)" +
641            r"((?P<dst>[^0-9:.+-]+|<[a-zA-Z0-9+\-]+>)" +
642                r"((?P<dstoff>[+-]?\d{1,2}(:\d{2}(:\d{2})?)?))?" +
643            r")?" + # dst
644        r")?$" # stdoff
645    )
646    # fmt: on
647
648    m = parser_re.match(offset_str)
649
650    if m is None:
651        raise ValueError(f"{tz_str} is not a valid TZ string")
652
653    std_abbr = m.group("std")
654    dst_abbr = m.group("dst")
655    dst_offset = None
656
657    std_abbr = std_abbr.strip("<>")
658
659    if dst_abbr:
660        dst_abbr = dst_abbr.strip("<>")
661
662    if std_offset := m.group("stdoff"):
663        try:
664            std_offset = _parse_tz_delta(std_offset)
665        except ValueError as e:
666            raise ValueError(f"Invalid STD offset in {tz_str}") from e
667    else:
668        std_offset = 0
669
670    if dst_abbr is not None:
671        if dst_offset := m.group("dstoff"):
672            try:
673                dst_offset = _parse_tz_delta(dst_offset)
674            except ValueError as e:
675                raise ValueError(f"Invalid DST offset in {tz_str}") from e
676        else:
677            dst_offset = std_offset + 3600
678
679        if not start_end_str:
680            raise ValueError(f"Missing transition rules: {tz_str}")
681
682        start_end_strs = start_end_str[0].split(",", 1)
683        try:
684            start, end = (_parse_dst_start_end(x) for x in start_end_strs)
685        except ValueError as e:
686            raise ValueError(f"Invalid TZ string: {tz_str}") from e
687
688        return _TZStr(std_abbr, std_offset, dst_abbr, dst_offset, start, end)
689    elif start_end_str:
690        raise ValueError(f"Transition rule present without DST: {tz_str}")
691    else:
692        # This is a static ttinfo, don't return _TZStr
693        return _ttinfo(
694            _load_timedelta(std_offset), _load_timedelta(0), std_abbr
695        )
696
697
698def _parse_dst_start_end(dststr):
699    date, *time = dststr.split("/")
700    if date[0] == "M":
701        n_is_julian = False
702        m = re.match(r"M(\d{1,2})\.(\d).(\d)$", date)
703        if m is None:
704            raise ValueError(f"Invalid dst start/end date: {dststr}")
705        date_offset = tuple(map(int, m.groups()))
706        offset = _CalendarOffset(*date_offset)
707    else:
708        if date[0] == "J":
709            n_is_julian = True
710            date = date[1:]
711        else:
712            n_is_julian = False
713
714        doy = int(date)
715        offset = _DayOffset(doy, n_is_julian)
716
717    if time:
718        time_components = list(map(int, time[0].split(":")))
719        n_components = len(time_components)
720        if n_components < 3:
721            time_components.extend([0] * (3 - n_components))
722        offset.hour, offset.minute, offset.second = time_components
723
724    return offset
725
726
727def _parse_tz_delta(tz_delta):
728    match = re.match(
729        r"(?P<sign>[+-])?(?P<h>\d{1,2})(:(?P<m>\d{2})(:(?P<s>\d{2}))?)?",
730        tz_delta,
731    )
732    # Anything passed to this function should already have hit an equivalent
733    # regular expression to find the section to parse.
734    assert match is not None, tz_delta
735
736    h, m, s = (
737        int(v) if v is not None else 0
738        for v in map(match.group, ("h", "m", "s"))
739    )
740
741    total = h * 3600 + m * 60 + s
742
743    if not -86400 < total < 86400:
744        raise ValueError(
745            f"Offset must be strictly between -24h and +24h: {tz_delta}"
746        )
747
748    # Yes, +5 maps to an offset of -5h
749    if match.group("sign") != "-":
750        total *= -1
751
752    return total
753