python (3.12.0)

(root)/
lib/
python3.12/
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          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  
     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 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  
     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      # 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  
     698  def _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  
     727  def _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