Coverage for core / src / sensorkit / common / time.py: 96%

74 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-02 00:03 +0000

1# SPDX-License-Identifier: Apache-2.0 

2"""Time range parsing utilities supporting symbolic and absolute time specifications.""" 

3 

4import functools 

5import re 

6import zoneinfo 

7from datetime import UTC, datetime, timedelta 

8from typing import Callable 

9 

10from dateutil.parser import parse as parse_datetime 

11from dateutil.tz import tzlocal, tzoffset, tzutc 

12 

13_pattern = re.compile( 

14 r"(?P<symbol>[A-Za-z_]+)\s*(?:(?P<op>[+-])\s*(?P<deltaspec>.*))?|(?P<timespec>.+)" 

15) 

16_ref_dt = datetime(year=2000, month=1, day=1, tzinfo=UTC) 

17_ref_date = _ref_dt.date() 

18 

19type SymbolParseHandler = Callable[[datetime, datetime, bool], datetime] 

20 

21 

22@functools.cache 

23def _timezones(): 

24 now = datetime.now(UTC) 

25 return { 

26 info.tzname(now): info 

27 for info in ( 

28 zoneinfo.ZoneInfo(zone) for zone in zoneinfo.available_timezones() 

29 ) 

30 } 

31 

32 

33def clear_timezone_cache(): 

34 """Invalidate the cached timezone name-to-ZoneInfo mapping so it is rebuilt on next access.""" 

35 _timezones.cache_clear() 

36 

37 

38def get_tzinfo(name: str | None, offset: int | None = None): 

39 """Resolve a timezone name and optional UTC offset (in seconds) to a `tzinfo` object. 

40 

41 Returns the local timezone when both arguments are absent. Raises `TimeRangeError` for 

42 unrecognized timezone names. 

43 """ 

44 if not name and offset is None: 

45 return tzlocal() 

46 

47 if name: 

48 tz = _timezones().get(name) 

49 

50 if not tz: 

51 raise TimeRangeError(f"unknown timezone: {name}") 

52 else: 

53 tz = tzutc() 

54 

55 if offset is not None: 

56 offset = int(tz.utcoffset(datetime.now()).total_seconds()) - offset 

57 return tzoffset(f"UTC{offset // 36:+d}", offset) 

58 

59 return tz 

60 

61 

62def parse_time_of_day(spec: str, range_min: datetime, range_max: datetime): 

63 """Parse a time-of-day string into the first matching `datetime` within `[range_min, range_max]`. 

64 

65 Raises `TimeRangeParseError` if `spec` contains a date component, or `TimeRangeError` if 

66 the resolved time falls outside the allowed range. 

67 """ 

68 # Parse the input spec. 

69 dt = parse_datetime(spec, default=_ref_dt, tzinfos=get_tzinfo) 

70 

71 # Make sure the spec did not contain a date component. 

72 if dt.date() != _ref_date: 

73 raise TimeRangeParseError("input must be time of day, not date") 

74 

75 # Find the first time in the allowed range that matches the parsed time. 

76 if range_min.tzinfo != dt.tzinfo: 

77 # Make sure timezones match before combining. 

78 range_min = range_min.astimezone(dt.tzinfo) 

79 

80 dt = datetime.combine(range_min.date(), dt.timetz()) 

81 

82 if dt < range_min: 

83 dt += timedelta(days=1) 

84 

85 if dt > range_max: 

86 raise TimeRangeError(f"input is outside allowed range ({dt} > {range_max})") 

87 

88 return dt 

89 

90 

91def parse_time_delta(spec: str): 

92 """Parse a time-of-day string as a `timedelta` relative to midnight of the reference date.""" 

93 # Use a default reference datetime to extract the delta. 

94 delta_dt = parse_datetime(spec, default=_ref_dt) 

95 

96 if delta_dt.date() != _ref_date: 

97 raise TimeRangeParseError("delta must be time of day, not date") 

98 

99 return delta_dt - _ref_dt 

100 

101 

102def parse_spec( 

103 spec: str, 

104 range_min: datetime, 

105 range_max: datetime, 

106 symbol_handlers: dict[str, SymbolParseHandler] | None = None, 

107 latest_match: bool = False, 

108) -> datetime: 

109 """Resolve a time spec string to a `datetime` within a one-day window. 

110 

111 The spec may be an absolute time-of-day string or a symbol (optionally followed by a 

112 `+`/`-` delta). Symbol resolution is delegated to `symbol_handlers`. 

113 """ 

114 assert range_max - range_min <= timedelta(days=1) 

115 

116 # Parse the input spec. 

117 match = _pattern.match(spec) 

118 

119 if not match: 

120 raise TimeRangeParseError("input not recognized") 

121 

122 if timespec := match["timespec"]: 

123 return parse_time_of_day(timespec.upper(), range_min, range_max) 

124 else: 

125 symbol_handlers = symbol_handlers or {} 

126 handler_func = symbol_handlers.get(match["symbol"]) 

127 

128 if not handler_func: 

129 raise TimeRangeParseError(f"symbol '{match['symbol']}' not recognized") 

130 

131 if deltaspec := match["deltaspec"]: 

132 delta = parse_time_delta(deltaspec.lower()) * (-1 if match["op"] == "-" else 1) 

133 else: 

134 delta = timedelta(0) 

135 

136 # Use the matching handler to evaluate the range into a datetime. 

137 dt = handler_func( 

138 range_min - delta, 

139 range_max - delta, 

140 latest_match, 

141 ) 

142 

143 if dt is None: 

144 raise TimeRangeError( 

145 f"could not evaluate '{match['symbol']}'" 

146 f" between {range_min - delta} and {range_max - delta}" 

147 ) 

148 

149 return dt + delta 

150 

151 

152def parse_time_range( 

153 start_spec: str, 

154 end_spec: str, 

155 time_ref: datetime | None = None, 

156 symbol_handlers: dict[str, SymbolParseHandler] | None = None, 

157): 

158 """Parse a start and end spec into a `(start_dt, end_dt)` tuple relative to `time_ref`. 

159 

160 The end time is the earliest match within one day after `time_ref`; the start time is the 

161 latest match within one day before the resolved end time. `time_ref` defaults to the current 

162 UTC time if not provided. 

163 """ 

164 if time_ref: 

165 if time_ref.tzinfo is None: 

166 raise ValueError("time_ref must have a timezone") 

167 else: 

168 time_ref = datetime.now(UTC) 

169 

170 # End time is the earliest spec match within one day following the input reference time. 

171 end_dt = parse_spec( 

172 end_spec, 

173 time_ref, 

174 time_ref + timedelta(days=1), 

175 symbol_handlers=symbol_handlers, 

176 ) 

177 

178 # Start time is the latest spec match within one day preceding the end time. 

179 start_dt = parse_spec( 

180 start_spec, 

181 end_dt - timedelta(days=1), 

182 end_dt, 

183 symbol_handlers=symbol_handlers, 

184 latest_match=True, 

185 ) 

186 

187 return start_dt, end_dt 

188 

189 

190class TimeRangeError(Exception): 

191 """Raised when a resolved time is outside the permitted range or cannot be evaluated.""" 

192 

193 

194class TimeRangeParseError(TimeRangeError): 

195 """Raised when a time spec string cannot be parsed or is structurally invalid."""