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

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

134 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 ) -> None: 

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 action(self) -> str: 

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

93 

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

95 """ 

96 return self.alarm.ACTION 

97 

98 @property 

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

100 """The component that contains the alarm. 

101 

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

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

104 """ 

105 return self._parent 

106 

107 def is_active(self) -> bool: 

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

109 

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

111 views the alarm message and dismisses it. 

112 

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

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

115 

116 Raises: 

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

118 """ 

119 acknowledged = self.acknowledged 

120 if not acknowledged: 

121 return True 

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

123 return True 

124 trigger = self.trigger 

125 if trigger.tzinfo is None: 

126 raise LocalTimezoneMissing( 

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

128 "Use Alarms.set_local_timezone()." 

129 ) 

130 return trigger > acknowledged 

131 

132 @property 

133 def trigger(self) -> date: 

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

135 

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

137 """ 

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

139 return self._snooze_until 

140 return self._trigger 

141 

142 

143class Alarms: 

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

145 

146 This is an example using RFC 9074. 

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

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

149 

150 >>> from icalendar import Event, Alarms 

151 >>> event = Event.from_ical( 

152 ... '''BEGIN:VEVENT 

153 ... CREATED:20210301T151004Z 

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

155 ... DTSTAMP:20210301T151004Z 

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

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

158 ... SUMMARY:Meeting 

159 ... BEGIN:VALARM 

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

161 ... TRIGGER:-PT30M 

162 ... ACKNOWLEDGED:20210302T150004Z 

163 ... DESCRIPTION:Event reminder 

164 ... ACTION:DISPLAY 

165 ... END:VALARM 

166 ... BEGIN:VALARM 

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

168 ... TRIGGER:-PT15M 

169 ... DESCRIPTION:Event reminder 

170 ... ACTION:DISPLAY 

171 ... END:VALARM 

172 ... END:VEVENT 

173 ... ''') 

174 >>> alarms = Alarms(event) 

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

176 2 

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

178 1 

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

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

181 

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

183 This is not implemented yet. 

184 """ 

185 

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

187 """Start computing alarm times.""" 

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

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

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

191 self._start: date | None = None 

192 self._end: date | None = None 

193 self._parent: Parent | None = None 

194 self._last_ack: datetime | None = None 

195 self._snooze_until: datetime | None = None 

196 self._local_tzinfo: tzinfo | None = None 

197 

198 if component is not None: 

199 self.add_component(component) 

200 

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

202 """Add a component. 

203 

204 If this is an alarm, it is added. 

205 Events and Todos are added as a parent and all 

206 their alarms are added, too. 

207 """ 

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

209 self.set_parent(component) 

210 self.set_start(component.start) 

211 self.set_end(component.end) 

212 if component.is_thunderbird(): 

213 self.acknowledge_until(component.X_MOZ_LASTACK) 

214 self.snooze_until(component.X_MOZ_SNOOZE_TIME) 

215 else: 

216 self.acknowledge_until(component.DTSTAMP) 

217 

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

219 self.add_alarm(alarm) 

220 

221 def set_parent(self, parent: Parent): 

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

223 

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

225 """ 

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

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

228 self._parent = parent 

229 

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

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

232 trigger = alarm.TRIGGER 

233 if trigger is None: 

234 return 

235 if isinstance(trigger, date): 

236 self._absolute_alarms.append(alarm) 

237 elif alarm.TRIGGER_RELATED == "START": 

238 self._start_alarms.append(alarm) 

239 else: 

240 self._end_alarms.append(alarm) 

241 

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

243 """Set the start of the component. 

244 

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

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

247 """ 

248 self._start = dt 

249 

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

251 """Set the end of the component. 

252 

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

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

255 """ 

256 self._end = dt 

257 

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

259 """Add a timedelta to a datetime.""" 

260 if is_date(dt): 

261 if td.seconds == 0: 

262 return dt + td 

263 dt = to_datetime(dt) 

264 return normalize_pytz(dt + td) 

265 

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

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

268 

269 Only the last call counts. 

270 

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

272 calendar implementations differ in how they acknowledge alarms. 

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

274 an event has been acknowledged because of an alarm. 

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

276 """ 

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

278 

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

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

281 

282 Only the last call counts. 

283 

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

285 but snoozed. 

286 """ 

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

288 

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

290 """Set the local timezone. 

291 

292 Events are sometimes in local time. 

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

294 alarms without timezone are considered local. 

295 

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

297 If they need this information, expect a 

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

299 somewhere down the line. 

300 """ 

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

302 

303 @property 

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

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

306 

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

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

309 

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

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

312 """ 

313 return ( 

314 self._get_end_alarm_times() 

315 + self._get_start_alarm_times() 

316 + self._get_absolute_alarm_times() 

317 ) 

318 

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

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

321 yield first # we trigger at the start 

322 repeat = alarm.repeat 

323 duration = alarm.DURATION 

324 if repeat and duration: 

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

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

327 

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

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

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

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

332 return AlarmTime( 

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

334 ) 

335 

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

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

338 return [ 

339 self._alarm_time(alarm, trigger) 

340 for alarm in self._absolute_alarms 

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

342 ] 

343 

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

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

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

347 raise ComponentStartMissing( 

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

349 "start of a component." 

350 ) 

351 return [ 

352 self._alarm_time(alarm, trigger) 

353 for alarm in self._start_alarms 

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

355 ] 

356 

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

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

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

360 raise ComponentEndMissing( 

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

362 "of a component." 

363 ) 

364 return [ 

365 self._alarm_time(alarm, trigger) 

366 for alarm in self._end_alarms 

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

368 ] 

369 

370 @property 

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

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

373 

374 This considers snoozed alarms. 

375 

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

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

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

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

380 """ 

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

382 

383 

384__all__ = [ 

385 "AlarmTime", 

386 "Alarms", 

387 "ComponentEndMissing", 

388 "ComponentStartMissing", 

389 "IncompleteAlarmInformation", 

390]