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
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
1"""Compute the times and states of alarms.
3This takes different calendar software into account and the RFC 9074 (Alarm Extension).
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"""
14from __future__ import annotations
16from datetime import date, timedelta, tzinfo
17from typing import TYPE_CHECKING, overload
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
30if TYPE_CHECKING:
31 from collections.abc import Generator
32 from datetime import datetime
34 from icalendar.cal.alarm import Alarm
35 from icalendar.prop import vBinary, vUri
37Parent = Event | Todo
40class AlarmTime:
41 """Represents a computed alarm occurrence with its timing and state.
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 """
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.
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
73 @property
74 def acknowledged(self) -> datetime | None:
75 """The time in UTC at which this alarm was last acknowledged.
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)
86 @property
87 def alarm(self) -> Alarm:
88 """The alarm component."""
89 return self._alarm
91 @property
92 def action(self) -> str:
93 """The action invoked when this alarm triggers.
95 This delegates to :attr:`Alarm.ACTION <icalendar.cal.alarm.Alarm.ACTION>`.
96 """
97 return self.alarm.ACTION
99 @property
100 def uid(self) -> str:
101 """The persistent, globally unique identifier of this alarm.
103 This delegates to :attr:`Alarm.uid <icalendar.cal.alarm.Alarm.uid>`.
104 """
105 return self.alarm.uid
107 @property
108 def summary(self) -> str | None:
109 """The short summary or subject for this alarm.
111 This delegates to :attr:`Alarm.summary <icalendar.cal.alarm.Alarm.summary>`.
112 """
113 return self.alarm.summary
115 @property
116 def description(self) -> str | None:
117 """A more complete description of the alarm than that provided by the
118 SUMMARY property.
120 This delegates to :attr:`Alarm.description
121 <icalendar.cal.alarm.Alarm.description>`.
122 """
123 return self.alarm.description
125 @property
126 def attendees(self) -> list[str]:
127 """List of email addresses to notify when this alarm is triggered.
129 This delegates to :attr:`Alarm.attendees
130 <icalendar.cal.alarm.Alarm.attendees>`.
131 """
132 return self.alarm.attendees
134 @property
135 def attachments(self) -> list[vUri | vBinary]:
136 """The attachments of this alarm.
138 This delegates to :attr:`Alarm.attachments
139 <icalendar.cal.alarm.Alarm.attachments>`.
140 """
141 return self.alarm.attachments
143 @property
144 def parent(self) -> Parent | None:
145 """The component that contains the alarm.
147 This is ``None`` if you didn't use :meth:`Alarms.add_component()
148 <icalendar.alarms.Alarms.add_component>`.
149 """
150 return self._parent
152 def is_active(self) -> bool:
153 """Whether this alarm is active (``True``) or acknowledged (``False``).
155 For example, in some calendar software, this is ``True`` until the user
156 views the alarm message and dismisses it.
158 Alarms can be in local time without a timezone. To calculate whether
159 the alarm has occurred, the time must include timezone information.
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
177 @property
178 def trigger(self) -> date:
179 """The time at which the alarm triggers.
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
188class Alarms:
189 """Compute the times and states of alarms.
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.
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'))
227 RFC 9074 specifies that alarms can also be triggered by proximity.
228 This is not implemented yet.
229 """
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
243 if component is not None:
244 self.add_component(component)
246 def add_component(self, component: Alarm | Parent) -> None:
247 """Add a component.
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)
263 for alarm in component.walk("VALARM"):
264 self.add_alarm(alarm)
266 def set_parent(self, parent: Parent) -> None:
267 """Set the parent of all the alarms.
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
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)
287 def set_start(self, dt: date | None) -> None:
288 """Set the start of the component.
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
295 def set_end(self, dt: date | None) -> None:
296 """Set the end of the component.
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
303 @overload
304 def _add(self, dt: datetime, td: timedelta) -> datetime: ...
306 @overload
307 def _add(self, dt: date, td: timedelta) -> date: ...
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)
317 def acknowledge_until(self, dt: date | None) -> None:
318 """The time in UTC when all the alarms of this component were acknowledged.
320 Only the last call counts.
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
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.
333 Only the last call counts.
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
340 def set_local_timezone(self, tzinfo: tzinfo | str | None) -> None:
341 """Set the local timezone.
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.
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
354 @property
355 def times(self) -> list[AlarmTime]:
356 """Compute and return the times of the alarms given.
358 If the information for calculation is incomplete, this will raise a
359 :exc:`~icalendar.error.IncompleteAlarmInformation` exception.
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 )
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)
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 )
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 ]
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 ]
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 ]
421 @property
422 def active(self) -> list[AlarmTime]:
423 """The alarm times that are still active and not acknowledged.
425 This considers snoozed alarms.
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()]
435__all__ = [
436 "AlarmTime",
437 "Alarms",
438 "ComponentEndMissing",
439 "ComponentStartMissing",
440 "IncompleteAlarmInformation",
441]