Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/icalendar/alarms.py: 33%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

131 statements  

1"""Compute the times and states of alarms. 

2 

3This takes different calendar software into account and the RFC 9074 (Alarm Extension). 

4 

5- RFC 9074 defines an ACKNOWLEDGED property in the VALARM. 

6- Outlook does not export VALARM information. 

7- Google Calendar uses the DTSTAMP to acknowledge the alarms. 

8- Thunderbird snoozes the alarms with a X-MOZ-SNOOZE-TIME attribute in the event. 

9- Thunderbird acknowledges the alarms with a X-MOZ-LASTACK attribute in the event. 

10- Etar deletes alarms that are acknowledged. 

11- Nextcloud's Webinterface does not do anything with the alarms when the time passes. 

12""" 

13 

14from __future__ import annotations 

15 

16from datetime import date, timedelta, tzinfo 

17from typing import TYPE_CHECKING 

18 

19from icalendar.cal.event import Event 

20from icalendar.cal.todo import Todo 

21from icalendar.error import ( 

22 ComponentEndMissing, 

23 ComponentStartMissing, 

24 IncompleteAlarmInformation, 

25 LocalTimezoneMissing, 

26) 

27from icalendar.timezone import tzp 

28from icalendar.tools import is_date, normalize_pytz, to_datetime 

29 

30if TYPE_CHECKING: 

31 from collections.abc import Generator 

32 from datetime import datetime 

33 

34 from icalendar.cal.alarm import Alarm 

35 

36Parent = Event | Todo 

37 

38 

39class AlarmTime: 

40 """Represents a computed alarm occurrence with its timing and state. 

41 

42 An AlarmTime instance combines an alarm component with its resolved 

43 trigger time and additional state information, such as acknowledgment 

44 and snoozing. 

45 """ 

46 

47 def __init__( 

48 self, 

49 alarm: Alarm, 

50 trigger: datetime, 

51 acknowledged_until: datetime | None = None, 

52 snoozed_until: datetime | None = None, 

53 parent: Parent | None = None, 

54 ): 

55 """Create an instance of ``AlarmTime`` with any of its parameters. 

56 

57 Parameters: 

58 alarm: The underlying alarm component. 

59 trigger: A date or datetime at which to trigger the alarm. 

60 acknowledged_until: Optional datetime in UTC until which 

61 the alarm has been acknowledged. 

62 snoozed_until: Optional datetime in UTC until which 

63 the alarm has been snoozed. 

64 parent: Optional parent component to which the alarm refers. 

65 """ 

66 self._alarm = alarm 

67 self._parent = parent 

68 self._trigger = trigger 

69 self._last_ack = acknowledged_until 

70 self._snooze_until = snoozed_until 

71 

72 @property 

73 def acknowledged(self) -> datetime | None: 

74 """The time in UTC at which this alarm was last acknowledged. 

75 

76 If the alarm was not acknowledged (dismissed), then this is None. 

77 """ 

78 ack = self.alarm.ACKNOWLEDGED 

79 if ack is None: 

80 return self._last_ack 

81 if self._last_ack is None: 

82 return ack 

83 return max(ack, self._last_ack) 

84 

85 @property 

86 def alarm(self) -> Alarm: 

87 """The alarm component.""" 

88 return self._alarm 

89 

90 @property 

91 def parent(self) -> Parent | None: 

92 """The component that contains the alarm. 

93 

94 This is ``None`` if you didn't use :meth:`Alarms.add_component() 

95 <icalendar.alarms.Alarms.add_component>`. 

96 """ 

97 return self._parent 

98 

99 def is_active(self) -> bool: 

100 """Whether this alarm is active (``True``) or acknowledged (``False``). 

101 

102 For example, in some calendar software, this is ``True`` until the user 

103 views the alarm message and dismisses it. 

104 

105 Alarms can be in local time without a timezone. To calculate whether 

106 the alarm has occurred, the time must include timezone information. 

107 

108 Raises: 

109 LocalTimezoneMissing: If a timezone is required but not given. 

110 """ 

111 acknowledged = self.acknowledged 

112 if not acknowledged: 

113 return True 

114 if self._snooze_until is not None and self._snooze_until > acknowledged: 

115 return True 

116 trigger = self.trigger 

117 if trigger.tzinfo is None: 

118 raise LocalTimezoneMissing( 

119 "A local timezone is required to check if the alarm is still active. " 

120 "Use Alarms.set_local_timezone()." 

121 ) 

122 return trigger > acknowledged 

123 

124 @property 

125 def trigger(self) -> date: 

126 """The time at which the alarm triggers. 

127 

128 If the alarm has been snoozed, this may differ from the TRIGGER property. 

129 """ 

130 if self._snooze_until is not None and self._snooze_until > self._trigger: 

131 return self._snooze_until 

132 return self._trigger 

133 

134 

135class Alarms: 

136 """Compute the times and states of alarms. 

137 

138 This is an example using RFC 9074. 

139 One alarm is 30 minutes before the event and acknowledged. 

140 Another alarm is 15 minutes before the event and still active. 

141 

142 >>> from icalendar import Event, Alarms 

143 >>> event = Event.from_ical( 

144 ... '''BEGIN:VEVENT 

145 ... CREATED:20210301T151004Z 

146 ... UID:AC67C078-CED3-4BF5-9726-832C3749F627 

147 ... DTSTAMP:20210301T151004Z 

148 ... DTSTART;TZID=America/New_York:20210302T103000 

149 ... DTEND;TZID=America/New_York:20210302T113000 

150 ... SUMMARY:Meeting 

151 ... BEGIN:VALARM 

152 ... UID:8297C37D-BA2D-4476-91AE-C1EAA364F8E1 

153 ... TRIGGER:-PT30M 

154 ... ACKNOWLEDGED:20210302T150004Z 

155 ... DESCRIPTION:Event reminder 

156 ... ACTION:DISPLAY 

157 ... END:VALARM 

158 ... BEGIN:VALARM 

159 ... UID:8297C37D-BA2D-4476-91AE-C1EAA364F8E1 

160 ... TRIGGER:-PT15M 

161 ... DESCRIPTION:Event reminder 

162 ... ACTION:DISPLAY 

163 ... END:VALARM 

164 ... END:VEVENT 

165 ... ''') 

166 >>> alarms = Alarms(event) 

167 >>> len(alarms.times) # all alarms including those acknowledged 

168 2 

169 >>> len(alarms.active) # the alarms that are not acknowledged, yet 

170 1 

171 >>> alarms.active[0].trigger # this alarm triggers 15 minutes before 10:30 

172 datetime.datetime(2021, 3, 2, 10, 15, tzinfo=ZoneInfo(key='America/New_York')) 

173 

174 RFC 9074 specifies that alarms can also be triggered by proximity. 

175 This is not implemented yet. 

176 """ 

177 

178 def __init__(self, component: Alarm | Event | Todo | None = None): 

179 """Start computing alarm times.""" 

180 self._absolute_alarms: list[Alarm] = [] 

181 self._start_alarms: list[Alarm] = [] 

182 self._end_alarms: list[Alarm] = [] 

183 self._start: date | None = None 

184 self._end: date | None = None 

185 self._parent: Parent | None = None 

186 self._last_ack: datetime | None = None 

187 self._snooze_until: datetime | None = None 

188 self._local_tzinfo: tzinfo | None = None 

189 

190 if component is not None: 

191 self.add_component(component) 

192 

193 def add_component(self, component: Alarm | Parent): 

194 """Add a component. 

195 

196 If this is an alarm, it is added. 

197 Events and Todos are added as a parent and all 

198 their alarms are added, too. 

199 """ 

200 if isinstance(component, (Event, Todo)): 

201 self.set_parent(component) 

202 self.set_start(component.start) 

203 self.set_end(component.end) 

204 if component.is_thunderbird(): 

205 self.acknowledge_until(component.X_MOZ_LASTACK) 

206 self.snooze_until(component.X_MOZ_SNOOZE_TIME) 

207 else: 

208 self.acknowledge_until(component.DTSTAMP) 

209 

210 for alarm in component.walk("VALARM"): 

211 self.add_alarm(alarm) 

212 

213 def set_parent(self, parent: Parent): 

214 """Set the parent of all the alarms. 

215 

216 If you would like to collect alarms from a component, use add_component 

217 """ 

218 if self._parent is not None and self._parent is not parent: 

219 raise ValueError("You can only set one parent for this alarm calculation.") 

220 self._parent = parent 

221 

222 def add_alarm(self, alarm: Alarm) -> None: 

223 """Optional: Add an alarm component.""" 

224 trigger = alarm.TRIGGER 

225 if trigger is None: 

226 return 

227 if isinstance(trigger, date): 

228 self._absolute_alarms.append(alarm) 

229 elif alarm.TRIGGER_RELATED == "START": 

230 self._start_alarms.append(alarm) 

231 else: 

232 self._end_alarms.append(alarm) 

233 

234 def set_start(self, dt: date | None): 

235 """Set the start of the component. 

236 

237 If you have only absolute alarms, this is not required. 

238 If you have alarms relative to the start of a compoment, set the start here. 

239 """ 

240 self._start = dt 

241 

242 def set_end(self, dt: date | None): 

243 """Set the end of the component. 

244 

245 If you have only absolute alarms, this is not required. 

246 If you have alarms relative to the end of a compoment, set the end here. 

247 """ 

248 self._end = dt 

249 

250 def _add(self, dt: date, td: timedelta): 

251 """Add a timedelta to a datetime.""" 

252 if is_date(dt): 

253 if td.seconds == 0: 

254 return dt + td 

255 dt = to_datetime(dt) 

256 return normalize_pytz(dt + td) 

257 

258 def acknowledge_until(self, dt: date | None) -> None: 

259 """The time in UTC when all the alarms of this component were acknowledged. 

260 

261 Only the last call counts. 

262 

263 Since RFC 9074 (Alarm Extension) was created later, 

264 calendar implementations differ in how they acknowledge alarms. 

265 For example, Thunderbird and Google Calendar store the last time 

266 an event has been acknowledged because of an alarm. 

267 All alarms that happen before this time count as acknowledged. 

268 """ 

269 self._last_ack = tzp.localize_utc(dt) if dt is not None else None 

270 

271 def snooze_until(self, dt: date | None) -> None: 

272 """This is the time in UTC when all the alarms of this component were snoozed. 

273 

274 Only the last call counts. 

275 

276 The alarms are supposed to turn up again at dt when they are not acknowledged 

277 but snoozed. 

278 """ 

279 self._snooze_until = tzp.localize_utc(dt) if dt is not None else None 

280 

281 def set_local_timezone(self, tzinfo: tzinfo | str | None): 

282 """Set the local timezone. 

283 

284 Events are sometimes in local time. 

285 In order to compute the exact time of the alarm, some 

286 alarms without timezone are considered local. 

287 

288 Some computations work without setting this, others don't. 

289 If they need this information, expect a LocalTimezoneMissing exception 

290 somewhere down the line. 

291 """ 

292 self._local_tzinfo = tzp.timezone(tzinfo) if isinstance(tzinfo, str) else tzinfo 

293 

294 @property 

295 def times(self) -> list[AlarmTime]: 

296 """Compute and return the times of the alarms given. 

297 

298 If the information for calculation is incomplete, this will raise a 

299 IncompleteAlarmInformation exception. 

300 

301 Please make sure to set all the required parameters before calculating. 

302 If you forget to set the acknowledged times, that is not problem. 

303 """ 

304 return ( 

305 self._get_end_alarm_times() 

306 + self._get_start_alarm_times() 

307 + self._get_absolute_alarm_times() 

308 ) 

309 

310 def _repeat(self, first: datetime, alarm: Alarm) -> Generator[datetime]: 

311 """The times when the alarm is triggered relative to start.""" 

312 yield first # we trigger at the start 

313 repeat = alarm.REPEAT 

314 duration = alarm.DURATION 

315 if repeat and duration: 

316 for i in range(1, repeat + 1): 

317 yield self._add(first, duration * i) 

318 

319 def _alarm_time(self, alarm: Alarm, trigger: date): 

320 """Create an alarm time with the additional attributes.""" 

321 if getattr(trigger, "tzinfo", None) is None and self._local_tzinfo is not None: 

322 trigger = normalize_pytz(trigger.replace(tzinfo=self._local_tzinfo)) 

323 return AlarmTime( 

324 alarm, trigger, self._last_ack, self._snooze_until, self._parent 

325 ) 

326 

327 def _get_absolute_alarm_times(self) -> list[AlarmTime]: 

328 """Return a list of absolute alarm times.""" 

329 return [ 

330 self._alarm_time(alarm, trigger) 

331 for alarm in self._absolute_alarms 

332 for trigger in self._repeat(alarm.TRIGGER, alarm) 

333 ] 

334 

335 def _get_start_alarm_times(self) -> list[AlarmTime]: 

336 """Return a list of alarm times relative to the start of the component.""" 

337 if self._start is None and self._start_alarms: 

338 raise ComponentStartMissing( 

339 "Use Alarms.set_start because at least one alarm is relative to the " 

340 "start of a component." 

341 ) 

342 return [ 

343 self._alarm_time(alarm, trigger) 

344 for alarm in self._start_alarms 

345 for trigger in self._repeat(self._add(self._start, alarm.TRIGGER), alarm) 

346 ] 

347 

348 def _get_end_alarm_times(self) -> list[AlarmTime]: 

349 """Return a list of alarm times relative to the start of the component.""" 

350 if self._end is None and self._end_alarms: 

351 raise ComponentEndMissing( 

352 "Use Alarms.set_end because at least one alarm is relative to the end " 

353 "of a component." 

354 ) 

355 return [ 

356 self._alarm_time(alarm, trigger) 

357 for alarm in self._end_alarms 

358 for trigger in self._repeat(self._add(self._end, alarm.TRIGGER), alarm) 

359 ] 

360 

361 @property 

362 def active(self) -> list[AlarmTime]: 

363 """The alarm times that are still active and not acknowledged. 

364 

365 This considers snoozed alarms. 

366 

367 Alarms can be in local time (without a timezone). 

368 To calculate if the alarm really happened, we need it to be in a timezone. 

369 If a timezone is required but not given, we throw an IncompleteAlarmInformation. 

370 """ 

371 return [alarm_time for alarm_time in self.times if alarm_time.is_active()] 

372 

373 

374__all__ = [ 

375 "AlarmTime", 

376 "Alarms", 

377 "ComponentEndMissing", 

378 "ComponentStartMissing", 

379 "IncompleteAlarmInformation", 

380]