(root)/
Python-3.11.7/
Lib/
zoneinfo/
_zoneinfo.py
       1  import bisect
       2  import calendar
       3  import collections
       4  import functools
       5  import re
       6  import weakref
       7  from datetime import datetime, timedelta, tzinfo
       8  
       9  from . import _common, _tzpath
      10  
      11  EPOCH = datetime(1970, 1, 1)
      12  EPOCHORDINAL = 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)
      26  def _load_timedelta(seconds):
      27      return timedelta(seconds=seconds)
      28  
      29  
      30  class ESC[4;38;5;81mZoneInfo(ESC[4;38;5;149mtzinfo):
      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 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  
     396  class ESC[4;38;5;81m_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  
     421  class ESC[4;38;5;81m_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  
     510  def _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  
     516  class ESC[4;38;5;81m_DayOffset:
     517      __slots__ = ["d", "julian", "hour", "minute", "second"]
     518  
     519      def __init__(self, d, julian, hour=2, minute=0, second=0):
     520          min_day = 0 + julian  # convert bool to int
     521          if not min_day <= d <= 365:
     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  
     543  class ESC[4;38;5;81m_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 1 <= m <= 12:
     564              raise ValueError("m must be in [1, 12]")
     565  
     566          if not 1 <= w <= 5:
     567              raise ValueError("w must be in [1, 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  
     623  def _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      parser_re = re.compile(
     638          r"""
     639          (?P<std>[^<0-9:.+-]+|<[a-zA-Z0-9+-]+>)
     640          (?:
     641              (?P<stdoff>[+-]?\d{1,3}(?::\d{2}(?::\d{2})?)?)
     642              (?:
     643                  (?P<dst>[^0-9:.+-]+|<[a-zA-Z0-9+-]+>)
     644                  (?P<dstoff>[+-]?\d{1,3}(?::\d{2}(?::\d{2})?)?)?
     645              )? # dst
     646          )? # stdoff
     647          """,
     648          re.ASCII|re.VERBOSE
     649      )
     650  
     651      m = parser_re.fullmatch(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  
     701  def _parse_dst_start_end(dststr):
     702      date, *time = dststr.split("/", 1)
     703      type = date[:1]
     704      if type == "M":
     705          n_is_julian = False
     706          m = re.fullmatch(r"M(\d{1,2})\.(\d).(\d)", date, re.ASCII)
     707          if m is None:
     708              raise ValueError(f"Invalid dst start/end date: {dststr}")
     709          date_offset = tuple(map(int, m.groups()))
     710          offset = _CalendarOffset(*date_offset)
     711      else:
     712          if type == "J":
     713              n_is_julian = True
     714              date = date[1:]
     715          else:
     716              n_is_julian = False
     717  
     718          doy = int(date)
     719          offset = _DayOffset(doy, n_is_julian)
     720  
     721      if time:
     722          offset.hour, offset.minute, offset.second = _parse_transition_time(time[0])
     723  
     724      return offset
     725  
     726  
     727  def _parse_transition_time(time_str):
     728      match = re.fullmatch(
     729          r"(?P<sign>[+-])?(?P<h>\d{1,3})(:(?P<m>\d{2})(:(?P<s>\d{2}))?)?",
     730          time_str,
     731          re.ASCII
     732      )
     733      if match is None:
     734          raise ValueError(f"Invalid time: {time_str}")
     735  
     736      h, m, s = (int(v or 0) for v in match.group("h", "m", "s"))
     737  
     738      if h > 167:
     739          raise ValueError(
     740              f"Hour must be in [0, 167]: {time_str}"
     741          )
     742  
     743      if match.group("sign") == "-":
     744          h, m, s = -h, -m, -s
     745  
     746      return h, m, s
     747  
     748  
     749  def _parse_tz_delta(tz_delta):
     750      match = re.fullmatch(
     751          r"(?P<sign>[+-])?(?P<h>\d{1,3})(:(?P<m>\d{2})(:(?P<s>\d{2}))?)?",
     752          tz_delta,
     753          re.ASCII
     754      )
     755      # Anything passed to this function should already have hit an equivalent
     756      # regular expression to find the section to parse.
     757      assert match is not None, tz_delta
     758  
     759      h, m, s = (int(v or 0) for v in match.group("h", "m", "s"))
     760  
     761      total = h * 3600 + m * 60 + s
     762  
     763      if h > 24:
     764          raise ValueError(
     765              f"Offset hours must be in [0, 24]: {tz_delta}"
     766          )
     767  
     768      # Yes, +5 maps to an offset of -5h
     769      if match.group("sign") != "-":
     770          total = -total
     771  
     772      return total