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

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

154 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, overload 

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 from icalendar.prop import vBinary, vUri 

36 

37Parent = Event | Todo 

38 

39 

40class AlarmTime: 

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

42 

43 An AlarmTime instance combines an alarm component with its resolved 

44 trigger time and additional state information, such as acknowledgment 

45 and snoozing. 

46 """ 

47 

48 def __init__( 

49 self, 

50 alarm: Alarm, 

51 trigger: datetime, 

52 acknowledged_until: datetime | None = None, 

53 snoozed_until: datetime | None = None, 

54 parent: Parent | None = None, 

55 ) -> None: 

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

57 

58 Parameters: 

59 alarm: The underlying alarm component. 

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

61 acknowledged_until: Optional datetime in UTC until which 

62 the alarm has been acknowledged. 

63 snoozed_until: Optional datetime in UTC until which 

64 the alarm has been snoozed. 

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

66 """ 

67 self._alarm = alarm 

68 self._parent = parent 

69 self._trigger = trigger 

70 self._last_ack = acknowledged_until 

71 self._snooze_until = snoozed_until 

72 

73 @property 

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

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

76 

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

78 """ 

79 ack = self.alarm.ACKNOWLEDGED 

80 if ack is None: 

81 return self._last_ack 

82 if self._last_ack is None: 

83 return ack 

84 return max(ack, self._last_ack) 

85 

86 @property 

87 def alarm(self) -> Alarm: 

88 """The alarm component.""" 

89 return self._alarm 

90 

91 @property 

92 def action(self) -> str: 

93 """The action invoked when this alarm triggers. 

94 

95 This delegates to :attr:`Alarm.ACTION <icalendar.cal.alarm.Alarm.ACTION>`. 

96 """ 

97 return self.alarm.ACTION 

98 

99 @property 

100 def uid(self) -> str: 

101 """The persistent, globally unique identifier of this alarm. 

102 

103 This delegates to :attr:`Alarm.uid <icalendar.cal.alarm.Alarm.uid>`. 

104 """ 

105 return self.alarm.uid 

106 

107 @property 

108 def summary(self) -> str | None: 

109 """The short summary or subject for this alarm. 

110 

111 This delegates to :attr:`Alarm.summary <icalendar.cal.alarm.Alarm.summary>`. 

112 """ 

113 return self.alarm.summary 

114 

115 @property 

116 def description(self) -> str | None: 

117 """A more complete description of the alarm than that provided by the 

118 SUMMARY property. 

119 

120 This delegates to :attr:`Alarm.description 

121 <icalendar.cal.alarm.Alarm.description>`. 

122 """ 

123 return self.alarm.description 

124 

125 @property 

126 def attendees(self) -> list[str]: 

127 """List of email addresses to notify when this alarm is triggered. 

128 

129 This delegates to :attr:`Alarm.attendees 

130 <icalendar.cal.alarm.Alarm.attendees>`. 

131 """ 

132 return self.alarm.attendees 

133 

134 @property 

135 def attachments(self) -> list[vUri | vBinary]: 

136 """The attachments of this alarm. 

137 

138 This delegates to :attr:`Alarm.attachments 

139 <icalendar.cal.alarm.Alarm.attachments>`. 

140 """ 

141 return self.alarm.attachments 

142 

143 @property 

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

145 """The component that contains the alarm. 

146 

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

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

149 """ 

150 return self._parent 

151 

152 def is_active(self) -> bool: 

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

154 

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

156 views the alarm message and dismisses it. 

157 

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

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

160 

161 Raises: 

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

163 """ 

164 acknowledged = self.acknowledged 

165 if not acknowledged: 

166 return True 

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

168 return True 

169 trigger = self.trigger 

170 if trigger.tzinfo is None: 

171 raise LocalTimezoneMissing( 

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

173 "Use Alarms.set_local_timezone()." 

174 ) 

175 return trigger > acknowledged 

176 

177 @property 

178 def trigger(self) -> date: 

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

180 

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

182 """ 

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

184 return self._snooze_until 

185 return self._trigger 

186 

187 

188class Alarms: 

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

190 

191 This is an example using RFC 9074. 

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

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

194 

195 >>> from icalendar import Event, Alarms 

196 >>> event = Event.from_ical( 

197 ... '''BEGIN:VEVENT 

198 ... CREATED:20210301T151004Z 

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

200 ... DTSTAMP:20210301T151004Z 

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

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

203 ... SUMMARY:Meeting 

204 ... BEGIN:VALARM 

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

206 ... TRIGGER:-PT30M 

207 ... ACKNOWLEDGED:20210302T150004Z 

208 ... DESCRIPTION:Event reminder 

209 ... ACTION:DISPLAY 

210 ... END:VALARM 

211 ... BEGIN:VALARM 

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

213 ... TRIGGER:-PT15M 

214 ... DESCRIPTION:Event reminder 

215 ... ACTION:DISPLAY 

216 ... END:VALARM 

217 ... END:VEVENT 

218 ... ''') 

219 >>> alarms = Alarms(event) 

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

221 2 

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

223 1 

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

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

226 

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

228 This is not implemented yet. 

229 """ 

230 

231 def __init__(self, component: Alarm | Event | Todo | None = None) -> None: 

232 """Start computing alarm times.""" 

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

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

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

236 self._start: date | None = None 

237 self._end: date | None = None 

238 self._parent: Parent | None = None 

239 self._last_ack: datetime | None = None 

240 self._snooze_until: datetime | None = None 

241 self._local_tzinfo: tzinfo | None = None 

242 

243 if component is not None: 

244 self.add_component(component) 

245 

246 def add_component(self, component: Alarm | Parent) -> None: 

247 """Add a component. 

248 

249 If this is an alarm, it is added. 

250 Events and Todos are added as a parent and all 

251 their alarms are added, too. 

252 """ 

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

254 self.set_parent(component) 

255 self.set_start(component.start) 

256 self.set_end(component.end) 

257 if component.is_thunderbird(): 

258 self.acknowledge_until(component.X_MOZ_LASTACK) 

259 self.snooze_until(component.X_MOZ_SNOOZE_TIME) 

260 else: 

261 self.acknowledge_until(component.DTSTAMP) 

262 

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

264 self.add_alarm(alarm) 

265 

266 def set_parent(self, parent: Parent) -> None: 

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

268 

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

270 """ 

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

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

273 self._parent = parent 

274 

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

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

277 trigger = alarm.TRIGGER 

278 if trigger is None: 

279 return 

280 if isinstance(trigger, date): 

281 self._absolute_alarms.append(alarm) 

282 elif alarm.TRIGGER_RELATED == "START": 

283 self._start_alarms.append(alarm) 

284 else: 

285 self._end_alarms.append(alarm) 

286 

287 def set_start(self, dt: date | None) -> None: 

288 """Set the start of the component. 

289 

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

291 If you have alarms relative to the start of a component, set the start here. 

292 """ 

293 self._start = dt 

294 

295 def set_end(self, dt: date | None) -> None: 

296 """Set the end of the component. 

297 

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

299 If you have alarms relative to the end of a component, set the end here. 

300 """ 

301 self._end = dt 

302 

303 @overload 

304 def _add(self, dt: datetime, td: timedelta) -> datetime: ... 

305 

306 @overload 

307 def _add(self, dt: date, td: timedelta) -> date: ... 

308 

309 def _add(self, dt: date, td: timedelta) -> date | datetime: 

310 """Add a timedelta to a datetime.""" 

311 if is_date(dt): 

312 if td.seconds == 0: 

313 return dt + td 

314 dt = to_datetime(dt) 

315 return normalize_pytz(dt + td) 

316 

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

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

319 

320 Only the last call counts. 

321 

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

323 calendar implementations differ in how they acknowledge alarms. 

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

325 an event has been acknowledged because of an alarm. 

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

327 """ 

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

329 

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

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

332 

333 Only the last call counts. 

334 

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

336 but snoozed. 

337 """ 

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

339 

340 def set_local_timezone(self, tzinfo: tzinfo | str | None) -> None: 

341 """Set the local timezone. 

342 

343 Events are sometimes in local time. 

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

345 alarms without timezone are considered local. 

346 

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

348 If they need this information, expect a 

349 :exc:`~icalendar.error.LocalTimezoneMissing` exception 

350 somewhere down the line. 

351 """ 

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

353 

354 @property 

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

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

357 

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

359 :exc:`~icalendar.error.IncompleteAlarmInformation` exception. 

360 

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

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

363 """ 

364 return ( 

365 self._get_end_alarm_times() 

366 + self._get_start_alarm_times() 

367 + self._get_absolute_alarm_times() 

368 ) 

369 

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

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

372 yield first # we trigger at the start 

373 repeat = alarm.repeat 

374 duration = alarm.DURATION 

375 if repeat and duration: 

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

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

378 

379 def _alarm_time(self, alarm: Alarm, trigger: date) -> AlarmTime: 

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

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

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

383 return AlarmTime( 

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

385 ) 

386 

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

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

389 return [ 

390 self._alarm_time(alarm, trigger) 

391 for alarm in self._absolute_alarms 

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

393 ] 

394 

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

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

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

398 raise ComponentStartMissing( 

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

400 "start of a component." 

401 ) 

402 return [ 

403 self._alarm_time(alarm, trigger) 

404 for alarm in self._start_alarms 

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

406 ] 

407 

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

409 """Return a list of alarm times relative to the end of the component.""" 

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

411 raise ComponentEndMissing( 

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

413 "of a component." 

414 ) 

415 return [ 

416 self._alarm_time(alarm, trigger) 

417 for alarm in self._end_alarms 

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

419 ] 

420 

421 @property 

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

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

424 

425 This considers snoozed alarms. 

426 

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

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

429 If a timezone is required but not given, we throw an 

430 :exc:`~icalendar.error.IncompleteAlarmInformation`. 

431 """ 

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

433 

434 

435__all__ = [ 

436 "AlarmTime", 

437 "Alarms", 

438 "ComponentEndMissing", 

439 "ComponentStartMissing", 

440 "IncompleteAlarmInformation", 

441]