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

130 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, Generator, Optional, Union 

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 datetime import datetime 

32 

33 from icalendar.cal.alarm import Alarm 

34 

35Parent = Union[Event, Todo] 

36 

37class AlarmTime: 

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

39 

40 An AlarmTime instance combines an alarm component with its resolved 

41 trigger time and additional state information, such as acknowledgment 

42 and snoozing. 

43 

44 Attributes: 

45 alarm (Alarm): The underlying VALARM component. 

46 trigger (datetime): The computed trigger time. 

47 acknowledged_until (datetime | None): Time in UTC until which the 

48 alarm has been acknowledged, if any. 

49 snoozed_until (datetime | None): Time in UTC until which the alarm 

50 is snoozed, if any. 

51 parent (Event | Todo | None): The parent calendar component that 

52 contains this alarm. 

53 """ 

54 

55 def __init__( 

56 self, 

57 alarm: Alarm, 

58 trigger: datetime, 

59 acknowledged_until: Optional[datetime] = None, 

60 snoozed_until: Optional[datetime] = None, 

61 parent: Optional[Parent] = None, 

62 ): 

63 """Create a new AlarmTime. 

64 

65 Parameters: 

66 alarm: The underlying alarm component. 

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

68 acknowledged_until: Optional datetime in UTC until which 

69 the alarm has been acknowledged. 

70 snoozed_until: Optional datetime in UTC until which 

71 the alarm has been snoozed. 

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

73 """ 

74 self._alarm = alarm 

75 self._parent = parent 

76 self._trigger = trigger 

77 self._last_ack = acknowledged_until 

78 self._snooze_until = snoozed_until 

79 

80 @property 

81 def acknowledged(self) -> Optional[datetime]: 

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

83 

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

85 """ 

86 ack = self.alarm.ACKNOWLEDGED 

87 if ack is None: 

88 return self._last_ack 

89 if self._last_ack is None: 

90 return ack 

91 return max(ack, self._last_ack) 

92 

93 @property 

94 def alarm(self) -> Alarm: 

95 """The alarm component.""" 

96 return self._alarm 

97 

98 @property 

99 def parent(self) -> Optional[Parent]: 

100 """The component that contains the alarm. 

101 

102 This is ``None`` if you didn't use :meth:`Alarms.add_component() <icalendar.alarms.Alarms.add_component>`. 

103 """ 

104 return self._parent 

105 

106 def is_active(self) -> bool: 

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

108 

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

110 views the alarm message and dismisses it. 

111 

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

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

114 

115 Raises: 

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

117 """ 

118 acknowledged = self.acknowledged 

119 if not acknowledged: 

120 return True 

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

122 return True 

123 trigger = self.trigger 

124 if trigger.tzinfo is None: 

125 raise LocalTimezoneMissing( 

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

127 "Use Alarms.set_local_timezone()." 

128 ) 

129 return trigger > acknowledged 

130 

131 @property 

132 def trigger(self) -> date: 

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

134 

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

136 """ 

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

138 return self._snooze_until 

139 return self._trigger 

140 

141 

142class Alarms: 

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

144 

145 This is an example using RFC 9074. 

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

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

148 

149 >>> from icalendar import Event, Alarms 

150 >>> event = Event.from_ical( 

151 ... '''BEGIN:VEVENT 

152 ... CREATED:20210301T151004Z 

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

154 ... DTSTAMP:20210301T151004Z 

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

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

157 ... SUMMARY:Meeting 

158 ... BEGIN:VALARM 

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

160 ... TRIGGER:-PT30M 

161 ... ACKNOWLEDGED:20210302T150004Z 

162 ... DESCRIPTION:Event reminder 

163 ... ACTION:DISPLAY 

164 ... END:VALARM 

165 ... BEGIN:VALARM 

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

167 ... TRIGGER:-PT15M 

168 ... DESCRIPTION:Event reminder 

169 ... ACTION:DISPLAY 

170 ... END:VALARM 

171 ... END:VEVENT 

172 ... ''') 

173 >>> alarms = Alarms(event) 

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

175 2 

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

177 1 

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

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

180 

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

182 This is not implemented yet. 

183 """ 

184 

185 def __init__(self, component: Optional[Alarm | Event | Todo] = None): 

186 """Start computing alarm times.""" 

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

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

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

190 self._start: Optional[date] = None 

191 self._end: Optional[date] = None 

192 self._parent: Optional[Parent] = None 

193 self._last_ack: Optional[datetime] = None 

194 self._snooze_until: Optional[datetime] = None 

195 self._local_tzinfo: Optional[tzinfo] = None 

196 

197 if component is not None: 

198 self.add_component(component) 

199 

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

201 """Add a component. 

202 

203 If this is an alarm, it is added. 

204 Events and Todos are added as a parent and all 

205 their alarms are added, too. 

206 """ 

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

208 self.set_parent(component) 

209 self.set_start(component.start) 

210 self.set_end(component.end) 

211 if component.is_thunderbird(): 

212 self.acknowledge_until(component.X_MOZ_LASTACK) 

213 self.snooze_until(component.X_MOZ_SNOOZE_TIME) 

214 else: 

215 self.acknowledge_until(component.DTSTAMP) 

216 

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

218 self.add_alarm(alarm) 

219 

220 def set_parent(self, parent: Parent): 

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

222 

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

224 """ 

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

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

227 self._parent = parent 

228 

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

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

231 trigger = alarm.TRIGGER 

232 if trigger is None: 

233 return 

234 if isinstance(trigger, date): 

235 self._absolute_alarms.append(alarm) 

236 elif alarm.TRIGGER_RELATED == "START": 

237 self._start_alarms.append(alarm) 

238 else: 

239 self._end_alarms.append(alarm) 

240 

241 def set_start(self, dt: Optional[date]): 

242 """Set the start of the component. 

243 

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

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

246 """ 

247 self._start = dt 

248 

249 def set_end(self, dt: Optional[date]): 

250 """Set the end of the component. 

251 

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

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

254 """ 

255 self._end = dt 

256 

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

258 """Add a timedelta to a datetime.""" 

259 if is_date(dt): 

260 if td.seconds == 0: 

261 return dt + td 

262 dt = to_datetime(dt) 

263 return normalize_pytz(dt + td) 

264 

265 def acknowledge_until(self, dt: Optional[date]) -> None: 

266 """This is the time in UTC when all the alarms of this component were acknowledged. 

267 

268 Only the last call counts. 

269 

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

271 calendar implementations differ in how they acknowledge alarms. 

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

273 an event has been acknowledged because of an alarm. 

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

275 """ 

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

277 

278 def snooze_until(self, dt: Optional[date]) -> None: 

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

280 

281 Only the last call counts. 

282 

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

284 but snoozed. 

285 """ 

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

287 

288 def set_local_timezone(self, tzinfo: Optional[tzinfo | str]): 

289 """Set the local timezone. 

290 

291 Events are sometimes in local time. 

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

293 alarms without timezone are considered local. 

294 

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

296 If they need this information, expect a LocalTimezoneMissing exception 

297 somewhere down the line. 

298 """ 

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

300 

301 @property 

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

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

304 

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

306 IncompleteAlarmInformation exception. 

307 

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

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

310 """ 

311 return ( 

312 self._get_end_alarm_times() 

313 + self._get_start_alarm_times() 

314 + self._get_absolute_alarm_times() 

315 ) 

316 

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

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

319 yield first # we trigger at the start 

320 repeat = alarm.REPEAT 

321 duration = alarm.DURATION 

322 if repeat and duration: 

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

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

325 

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

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

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

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

330 return AlarmTime( 

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

332 ) 

333 

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

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

336 return [ 

337 self._alarm_time(alarm, trigger) 

338 for alarm in self._absolute_alarms 

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

340 ] 

341 

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

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

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

345 raise ComponentStartMissing( 

346 "Use Alarms.set_start because at least one alarm is relative to the start of a component." 

347 ) 

348 return [ 

349 self._alarm_time(alarm, trigger) 

350 for alarm in self._start_alarms 

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

352 ] 

353 

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

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

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

357 raise ComponentEndMissing( 

358 "Use Alarms.set_end because at least one alarm is relative to the end of a component." 

359 ) 

360 return [ 

361 self._alarm_time(alarm, trigger) 

362 for alarm in self._end_alarms 

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

364 ] 

365 

366 @property 

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

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

369 

370 This considers snoozed alarms. 

371 

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

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

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

375 """ 

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

377 

378 

379__all__ = [ 

380 "AlarmTime", 

381 "Alarms", 

382 "ComponentEndMissing", 

383 "ComponentStartMissing", 

384 "IncompleteAlarmInformation", 

385]