def is_transition(self, dt1: datetime, dt2: datetime) -> bool: """Determine if dt1 -> dt2 is a UTC offset transition. If detect_dst_transition is True, then also detect DST offset transition. """ if dt1.utcoffset() != dt2.utcoffset(): return True if self.detect_dst_transition: return dt1.dst() != dt2.dst() return False
def _create_test_item(dt: datetime, tag: str) -> TestItem: """Create a TestItem from a datetime.""" unix_seconds = int(dt.timestamp()) epoch_seconds = unix_seconds - SECONDS_SINCE_UNIX_EPOCH total_offset = int(dt.utcoffset().total_seconds()) # type: ignore dst_offset = int(dt.dst().total_seconds()) # type: ignore # See https://stackoverflow.com/questions/5946499 for more info on how # to extract the abbreviation. dt.tzinfo will never be None because the # timezone will always be defined. assert dt.tzinfo is not None abbrev = dt.tzinfo.tzname(dt) return { 'epoch': epoch_seconds, 'total_offset': total_offset, 'dst_offset': dst_offset, 'y': dt.year, 'M': dt.month, 'd': dt.day, 'h': dt.hour, 'm': dt.minute, 's': dt.second, 'abbrev': abbrev, 'type': tag, }
def _write_timestamp(self, value: datetime.datetime) -> typing.NoReturn: """Write a datetime.datetime value :param datetime.datetime value: The value to write """ self._write_int(value.second) self._write_int(value.minute) self._write_int(value.hour) self._write_int(value.day) self._write_int(value.month - 1) self._write_int(value.year - 1900) self._write_int(1 if value.dst() else 0)
def recode_timezone_info(dt: datetime.datetime): name = dt.tzname() dst = dt.dst() dst = (' ' + dst) if dst != datetime.timedelta() else '' if name == 'UTC': return f'{name}{dst}' offset = dt.utcoffset() offset = ('+' if offset >= datetime.timedelta() else '') + str(offset.total_seconds() / 3600) if name is None or not name: return f'UTC{offset}{dst}' return f'{name} (UTC{offset}{dst})'
def find_next_time_expression_time(now: dt.datetime, seconds: List[int], minutes: List[int], hours: List[int]) -> dt.datetime: """Find the next datetime from now for which the time expression matches. The algorithm looks at each time unit separately and tries to find the next one that matches for each. If any of them would roll over, all time units below that are reset to the first matching value. Timezones are also handled (the tzinfo of the now object is used), including daylight saving time. """ if not seconds or not minutes or not hours: raise ValueError("Cannot find a next time: Time expression never " "matches!") def _lower_bound(arr: List[int], cmp: int) -> Optional[int]: """Return the first value in arr greater or equal to cmp. Return None if no such value exists. """ left = 0 right = len(arr) while left < right: mid = (left + right) // 2 if arr[mid] < cmp: left = mid + 1 else: right = mid if left == len(arr): return None return arr[left] result = now.replace(microsecond=0) # Match next second next_second = _lower_bound(seconds, result.second) if next_second is None: # No second to match in this minute. Roll-over to next minute. next_second = seconds[0] result += dt.timedelta(minutes=1) result = result.replace(second=next_second) # Match next minute next_minute = _lower_bound(minutes, result.minute) if next_minute != result.minute: # We're in the next minute. Seconds needs to be reset. result = result.replace(second=seconds[0]) if next_minute is None: # No minute to match in this hour. Roll-over to next hour. next_minute = minutes[0] result += dt.timedelta(hours=1) result = result.replace(minute=next_minute) # Match next hour next_hour = _lower_bound(hours, result.hour) if next_hour != result.hour: # We're in the next hour. Seconds+minutes needs to be reset. result = result.replace(second=seconds[0], minute=minutes[0]) if next_hour is None: # No minute to match in this day. Roll-over to next day. next_hour = hours[0] result += dt.timedelta(days=1) result = result.replace(hour=next_hour) if result.tzinfo is None: return result # Now we need to handle timezones. We will make this datetime object # "naive" first and then re-convert it to the target timezone. # This is so that we can call pytz's localize and handle DST changes. tzinfo = result.tzinfo # type: pytzinfo.DstTzInfo result = result.replace(tzinfo=None) try: result = tzinfo.localize(result, is_dst=None) except pytzexceptions.AmbiguousTimeError: # This happens when we're leaving daylight saving time and local # clocks are rolled back. In this case, we want to trigger # on both the DST and non-DST time. So when "now" is in the DST # use the DST-on time, and if not, use the DST-off time. use_dst = bool(now.dst()) result = tzinfo.localize(result, is_dst=use_dst) except pytzexceptions.NonExistentTimeError: # This happens when we're entering daylight saving time and local # clocks are rolled forward, thus there are local times that do # not exist. In this case, we want to trigger on the next time # that *does* exist. # In the worst case, this will run through all the seconds in the # time shift, but that's max 3600 operations for once per year result = result.replace(tzinfo=tzinfo) + dt.timedelta(seconds=1) return find_next_time_expression_time(result, seconds, minutes, hours) result_dst = cast(dt.timedelta, result.dst()) now_dst = cast(dt.timedelta, now.dst()) if result_dst >= now_dst: return result # Another edge-case when leaving DST: # When now is in DST and ambiguous *and* the next trigger time we *should* # trigger is ambiguous and outside DST, the excepts above won't catch it. # For example: if triggering on 2:30 and now is 28.10.2018 2:30 (in DST) # we should trigger next on 28.10.2018 2:30 (out of DST), but our # algorithm above would produce 29.10.2018 2:30 (out of DST) # Step 1: Check if now is ambiguous try: tzinfo.localize(now.replace(tzinfo=None), is_dst=None) return result except pytzexceptions.AmbiguousTimeError: pass # Step 2: Check if result of (now - DST) is ambiguous. check = now - now_dst check_result = find_next_time_expression_time(check, seconds, minutes, hours) try: tzinfo.localize(check_result.replace(tzinfo=None), is_dst=None) return result except pytzexceptions.AmbiguousTimeError: pass # OK, edge case does apply. We must override the DST to DST-off check_result = tzinfo.localize(check_result.replace(tzinfo=None), is_dst=False) return check_result
def find_next_time_expression_time( now: dt.datetime, # pylint: disable=redefined-outer-name seconds: list[int], minutes: list[int], hours: list[int], ) -> dt.datetime: """Find the next datetime from now for which the time expression matches. The algorithm looks at each time unit separately and tries to find the next one that matches for each. If any of them would roll over, all time units below that are reset to the first matching value. Timezones are also handled (the tzinfo of the now object is used), including daylight saving time. """ if not seconds or not minutes or not hours: raise ValueError("Cannot find a next time: Time expression never matches!") def _lower_bound(arr: list[int], cmp: int) -> int | None: """Return the first value in arr greater or equal to cmp. Return None if no such value exists. """ left = bisect.bisect_left(arr, cmp) if left == len(arr): return None return arr[left] result = now.replace(microsecond=0) # Match next second next_second = _lower_bound(seconds, result.second) if next_second is None: # No second to match in this minute. Roll-over to next minute. next_second = seconds[0] result += dt.timedelta(minutes=1) result = result.replace(second=next_second) # Match next minute next_minute = _lower_bound(minutes, result.minute) if next_minute != result.minute: # We're in the next minute. Seconds needs to be reset. result = result.replace(second=seconds[0]) if next_minute is None: # No minute to match in this hour. Roll-over to next hour. next_minute = minutes[0] result += dt.timedelta(hours=1) result = result.replace(minute=next_minute) # Match next hour next_hour = _lower_bound(hours, result.hour) if next_hour != result.hour: # We're in the next hour. Seconds+minutes needs to be reset. result = result.replace(second=seconds[0], minute=minutes[0]) if next_hour is None: # No minute to match in this day. Roll-over to next day. next_hour = hours[0] result += dt.timedelta(days=1) result = result.replace(hour=next_hour) if result.tzinfo in (None, UTC): return result if _datetime_ambiguous(result): # This happens when we're leaving daylight saving time and local # clocks are rolled back. In this case, we want to trigger # on both the DST and non-DST time. So when "now" is in the DST # use the DST-on time, and if not, use the DST-off time. fold = 1 if now.dst() else 0 if result.fold != fold: result = result.replace(fold=fold) if not _datetime_exists(result): # This happens when we're entering daylight saving time and local # clocks are rolled forward, thus there are local times that do # not exist. In this case, we want to trigger on the next time # that *does* exist. # In the worst case, this will run through all the seconds in the # time shift, but that's max 3600 operations for once per year return find_next_time_expression_time( result + dt.timedelta(seconds=1), seconds, minutes, hours ) # Another edge-case when leaving DST: # When now is in DST and ambiguous *and* the next trigger time we *should* # trigger is ambiguous and outside DST, the excepts above won't catch it. # For example: if triggering on 2:30 and now is 28.10.2018 2:30 (in DST) # we should trigger next on 28.10.2018 2:30 (out of DST), but our # algorithm above would produce 29.10.2018 2:30 (out of DST) if _datetime_ambiguous(now): check_result = find_next_time_expression_time( now + _dst_offset_diff(now), seconds, minutes, hours ) if _datetime_ambiguous(check_result): return check_result return result
def export_tzinfo(dt: datetime): return dt.replace(tzinfo=None), dt.replace(tzinfo=None).timestamp( ), dt.fold, dt.tzname(), dt.utcoffset(), dt.dst()
def from_transition_dt(cls, trans_tz: datetime): before_tz = trans_tz - timedelta(minutes=1) return cls(tzoffsetfrom=before_tz.utcoffset(), tzoffsetto=trans_tz.utcoffset(), dst=trans_tz.dst(), tzname=trans_tz.tzname())
def only_dst(self, dt1: datetime, dt2: datetime) -> bool: """Determine if dt1 -> dt2 is only a DST transition.""" if not self.detect_dst_transition: return False return dt1.utcoffset() == dt2.utcoffset() and dt1.dst() != dt2.dst()