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