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 

37 

38class AlarmTime: 

39 """An alarm time with all the information.""" 

40 

41 def __init__( 

42 self, 

43 alarm: Alarm, 

44 trigger: datetime, 

45 acknowledged_until: Optional[datetime] = None, 

46 snoozed_until: Optional[datetime] = None, 

47 parent: Optional[Parent] = None, 

48 ): 

49 """Create a new AlarmTime. 

50 

51 alarm 

52 the Alarm component 

53 

54 trigger 

55 a date or datetime at which to trigger the alarm 

56 

57 acknowledged_until 

58 an optional datetime in UTC until when all alarms 

59 have been acknowledged 

60 

61 snoozed_until 

62 an optional datetime in UTC until which all alarms of 

63 the same parent are snoozed 

64 

65 parent 

66 the optional parent component the alarm refers to 

67 

68 local_tzinfo 

69 the local timezone that events without tzinfo should have 

70 """ 

71 self._alarm = alarm 

72 self._parent = parent 

73 self._trigger = trigger 

74 self._last_ack = acknowledged_until 

75 self._snooze_until = snoozed_until 

76 

77 @property 

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

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

80 

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

82 """ 

83 ack = self.alarm.ACKNOWLEDGED 

84 if ack is None: 

85 return self._last_ack 

86 if self._last_ack is None: 

87 return ack 

88 return max(ack, self._last_ack) 

89 

90 @property 

91 def alarm(self) -> Alarm: 

92 """The alarm component.""" 

93 return self._alarm 

94 

95 @property 

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

97 """This is the component that contains the alarm. 

98 

99 This is None if you did not use Alarms.set_component(). 

100 """ 

101 return self._parent 

102 

103 def is_active(self) -> bool: 

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

105 

106 For example, in some calendar software, this is True until the user looks 

107 at the alarm message and clicked the dismiss button. 

108 

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

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

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

112 """ 

113 acknowledged = self.acknowledged 

114 if not acknowledged: 

115 # if nothing is acknowledged, this alarm counts 

116 return True 

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

118 return True 

119 trigger = self.trigger 

120 if trigger.tzinfo is None: 

121 raise LocalTimezoneMissing( 

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

123 "Use Alarms.set_local_timezone()." 

124 ) 

125 return trigger > acknowledged 

126 

127 @property 

128 def trigger(self) -> date: 

129 """This is the time to trigger the alarm. 

130 

131 If the alarm has been snoozed, this can differ from the TRIGGER property. 

132 """ 

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

134 return self._snooze_until 

135 return self._trigger 

136 

137 

138class Alarms: 

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

140 

141 This is an example using RFC 9074. 

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

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

144 

145 >>> from icalendar import Event, Alarms 

146 >>> event = Event.from_ical( 

147 ... '''BEGIN:VEVENT 

148 ... CREATED:20210301T151004Z 

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

150 ... DTSTAMP:20210301T151004Z 

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

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

153 ... SUMMARY:Meeting 

154 ... BEGIN:VALARM 

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

156 ... TRIGGER:-PT30M 

157 ... ACKNOWLEDGED:20210302T150004Z 

158 ... DESCRIPTION:Event reminder 

159 ... ACTION:DISPLAY 

160 ... END:VALARM 

161 ... BEGIN:VALARM 

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

163 ... TRIGGER:-PT15M 

164 ... DESCRIPTION:Event reminder 

165 ... ACTION:DISPLAY 

166 ... END:VALARM 

167 ... END:VEVENT 

168 ... ''') 

169 >>> alarms = Alarms(event) 

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

171 2 

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

173 1 

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

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

176 

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

178 This is not implemented yet. 

179 """ 

180 

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

182 """Start computing alarm times.""" 

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

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

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

186 self._start: Optional[date] = None 

187 self._end: Optional[date] = None 

188 self._parent: Optional[Parent] = None 

189 self._last_ack: Optional[datetime] = None 

190 self._snooze_until: Optional[datetime] = None 

191 self._local_tzinfo: Optional[tzinfo] = None 

192 

193 if component is not None: 

194 self.add_component(component) 

195 

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

197 """Add a component. 

198 

199 If this is an alarm, it is added. 

200 Events and Todos are added as a parent and all 

201 their alarms are added, too. 

202 """ 

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

204 self.set_parent(component) 

205 self.set_start(component.start) 

206 self.set_end(component.end) 

207 if component.is_thunderbird(): 

208 self.acknowledge_until(component.X_MOZ_LASTACK) 

209 self.snooze_until(component.X_MOZ_SNOOZE_TIME) 

210 else: 

211 self.acknowledge_until(component.DTSTAMP) 

212 

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

214 self.add_alarm(alarm) 

215 

216 def set_parent(self, parent: Parent): 

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

218 

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

220 """ 

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

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

223 self._parent = parent 

224 

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

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

227 trigger = alarm.TRIGGER 

228 if trigger is None: 

229 return 

230 if isinstance(trigger, date): 

231 self._absolute_alarms.append(alarm) 

232 elif alarm.TRIGGER_RELATED == "START": 

233 self._start_alarms.append(alarm) 

234 else: 

235 self._end_alarms.append(alarm) 

236 

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

238 """Set the start of the component. 

239 

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

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

242 """ 

243 self._start = dt 

244 

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

246 """Set the end of the component. 

247 

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

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

250 """ 

251 self._end = dt 

252 

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

254 """Add a timedelta to a datetime.""" 

255 if is_date(dt): 

256 if td.seconds == 0: 

257 return dt + td 

258 dt = to_datetime(dt) 

259 return normalize_pytz(dt + td) 

260 

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

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

263 

264 Only the last call counts. 

265 

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

267 calendar implementations differ in how they acknowledge alarms. 

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

269 an event has been acknowledged because of an alarm. 

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

271 """ 

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

273 

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

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

276 

277 Only the last call counts. 

278 

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

280 but snoozed. 

281 """ 

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

283 

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

285 """Set the local timezone. 

286 

287 Events are sometimes in local time. 

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

289 alarms without timezone are considered local. 

290 

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

292 If they need this information, expect a LocalTimezoneMissing exception 

293 somewhere down the line. 

294 """ 

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

296 

297 @property 

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

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

300 

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

302 IncompleteAlarmInformation exception. 

303 

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

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

306 """ 

307 return ( 

308 self._get_end_alarm_times() 

309 + self._get_start_alarm_times() 

310 + self._get_absolute_alarm_times() 

311 ) 

312 

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

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

315 yield first # we trigger at the start 

316 repeat = alarm.REPEAT 

317 duration = alarm.DURATION 

318 if repeat and duration: 

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

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

321 

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

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

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

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

326 return AlarmTime( 

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

328 ) 

329 

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

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

332 return [ 

333 self._alarm_time(alarm, trigger) 

334 for alarm in self._absolute_alarms 

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

336 ] 

337 

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

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

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

341 raise ComponentStartMissing( 

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

343 ) 

344 return [ 

345 self._alarm_time(alarm, trigger) 

346 for alarm in self._start_alarms 

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

348 ] 

349 

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

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

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

353 raise ComponentEndMissing( 

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

355 ) 

356 return [ 

357 self._alarm_time(alarm, trigger) 

358 for alarm in self._end_alarms 

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

360 ] 

361 

362 @property 

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

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

365 

366 This considers snoozed alarms. 

367 

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

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

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

371 """ 

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

373 

374 

375__all__ = [ 

376 "AlarmTime", 

377 "Alarms", 

378 "ComponentEndMissing", 

379 "ComponentStartMissing", 

380 "IncompleteAlarmInformation", 

381]