blob: de68380792f17c329b02d9ace3a3d6739d5e9c2e [file] [log] [blame]
Paul Ganssle62972d92020-05-16 04:20:06 -04001import bisect
2import calendar
3import collections
4import functools
Paul Ganssle62972d92020-05-16 04:20:06 -04005import re
Paul Ganssle62972d92020-05-16 04:20:06 -04006import weakref
Paul Ganssle364b5ea2020-05-29 09:34:30 -04007from datetime import datetime, timedelta, tzinfo
Paul Ganssle62972d92020-05-16 04:20:06 -04008
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
Christian Clausscfca4a62021-10-07 17:49:47 +0200341 # find the DST offset by this point, we're going to have to
Paul Ganssle62972d92020-05-16 04:20:06 -0400342 # 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(
aboddie5b9fbba2020-06-03 10:18:19 -0400745 f"Offset must be strictly between -24h and +24h: {tz_delta}"
Paul Ganssle62972d92020-05-16 04:20:06 -0400746 )
747
748 # Yes, +5 maps to an offset of -5h
749 if match.group("sign") != "-":
750 total *= -1
751
752 return total