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