ical.event

A grouping of component properties that describe a calendar event.

An event can be an activity (e.g. a meeting from 8am to 9am tomorrow) grouping of properties such as a summary or a description. An event will take up time on a calendar as an opaque time interval, but can alternatively have transparency set to transparent to prevent blocking of time as busy.

An event start and end time may either be a date and time or just a day alone. Events may also span more than one day. Alternatively, an event can have a start and a duration.

  1"""A grouping of component properties that describe a calendar event.
  2
  3An event can be an activity (e.g. a meeting from 8am to 9am tomorrow)
  4grouping of properties such as a summary or a description. An event will
  5take up time on a calendar as an opaque time interval, but can alternatively
  6have transparency set to transparent to prevent blocking of time as busy.
  7
  8An event start and end time may either be a date and time or just a day
  9alone. Events may also span more than one day. Alternatively, an event
 10can have a start and a duration.
 11"""
 12
 13# pylint: disable=unnecessary-lambda
 14
 15from __future__ import annotations
 16
 17import datetime
 18import enum
 19import logging
 20from collections.abc import Iterable
 21from typing import Annotated, Any, Optional, Self, Union
 22
 23from pydantic import BeforeValidator, Field, field_serializer, model_validator
 24
 25from ical.compat import duration_dtend_compat, same_day_dtend_compat
 26from ical.types.data_types import serialize_field
 27
 28from .alarm import Alarm
 29from .component import (
 30    ComponentModel,
 31    validate_duration_unit,
 32    validate_until_dtstart,
 33    validate_recurrence_dates,
 34)
 35from .iter import RulesetIterable, as_rrule
 36from .timespan import Timespan
 37from .types import (
 38    Attachment,
 39    CalAddress,
 40    Classification,
 41    Conference,
 42    ExtraProperty,
 43    Geo,
 44    Image,
 45    Priority,
 46    Recur,
 47    RecurrenceId,
 48    RequestStatus,
 49    Uri,
 50    RelatedTo,
 51    Period,
 52)
 53from .util import (
 54    dtstamp_factory,
 55    normalize_datetime,
 56    parse_date_and_datetime,
 57    parse_date_and_datetime_list,
 58    parse_rdate_list,
 59    uid_factory,
 60)
 61
 62_LOGGER = logging.getLogger(__name__)
 63
 64__all__ = ["Event", "EventStatus"]
 65
 66
 67class EventStatus(str, enum.Enum):
 68    """Status or confirmation of the event set by the organizer."""
 69
 70    CONFIRMED = "CONFIRMED"
 71    """Indicates event is definite."""
 72
 73    TENTATIVE = "TENTATIVE"
 74    """Indicates event is tentative."""
 75
 76    CANCELLED = "CANCELLED"
 77    """Indicates event was cancelled."""
 78
 79
 80class Event(ComponentModel):
 81    """A single event on a calendar.
 82
 83    Can either be for a specific day, or with a start time and duration/end time.
 84
 85    The dtstamp and uid functions have factory methods invoked with a lambda to facilitate
 86    mocking in unit tests.
 87
 88
 89    Example:
 90    ```python
 91    import datetime
 92    from ical.event import Event
 93
 94    event = Event(
 95        dtstart=datetime.datetime(2022, 8, 31, 7, 00, 00),
 96        dtend=datetime.datetime(2022, 8, 31, 7, 30, 00),
 97        summary="Morning exercise",
 98    )
 99    print("The event duration is: ", event.computed_duration)
100    ```
101
102    An Event is a pydantic model, so all properties of a pydantic model apply here to such as
103    the constructor arguments, properties to return the model as a dictionary or json, as well
104    as other parsing methods.
105    """
106
107    dtstamp: Annotated[
108        Union[datetime.date, datetime.datetime],
109        BeforeValidator(parse_date_and_datetime),
110    ] = Field(default_factory=lambda: dtstamp_factory())
111    """Specifies the date and time the event was created."""
112
113    uid: str = Field(default_factory=lambda: uid_factory())
114    """A globally unique identifier for the event."""
115
116    # Has an alias of 'start'
117    dtstart: Annotated[
118        Union[datetime.date, datetime.datetime, None],
119        BeforeValidator(parse_date_and_datetime),
120    ] = Field(default=None)
121    """The start time or start day of the event."""
122
123    # Has an alias of 'end'
124    dtend: Annotated[
125        Union[datetime.date, datetime.datetime, None],
126        BeforeValidator(parse_date_and_datetime),
127    ] = None
128    """The end time or end day of the event.
129
130    This may be specified as an explicit date. Alternatively, a duration
131    can be used instead.
132    """
133
134    duration: Optional[datetime.timedelta] = None
135    """The duration of the event as an alternative to an explicit end date/time."""
136
137    summary: Optional[str] = None
138    """Defines a short summary or subject for the event."""
139
140    attendees: list[CalAddress] = Field(alias="attendee", default_factory=list)
141    """Specifies participants in a group-scheduled calendar."""
142
143    categories: list[str] = Field(default_factory=list)
144    """Defines the categories for an event.
145
146    Specifies a category or subtype. Can be useful for searching for a particular
147    type of event.
148    """
149
150    classification: Optional[Classification] = Field(alias="class", default=None)
151    """An access classification for a calendar event.
152
153    This provides a method of capturing the scope of access of a calendar, in
154    conjunction with an access control system.
155    """
156
157    comment: list[str] = Field(default_factory=list)
158    """Specifies a comment to the calendar user."""
159
160    contacts: list[str] = Field(alias="contact", default_factory=list)
161    """Contact information associated with the event."""
162
163    created: Optional[datetime.datetime] = None
164    """The date and time the event information was created."""
165
166    description: Optional[str] = None
167    """A more complete description of the event than provided by the summary."""
168
169    geo: Optional[Geo] = None
170    """Specifies a latitude and longitude global position for the event activity."""
171
172    last_modified: Optional[datetime.datetime] = Field(
173        alias="last-modified", default=None
174    )
175
176    color: Optional[str] = None
177    """Specifies a color associated with the event.
178
179    The value MUST be a case-insensitive color name defined in CSS3-Color (e.g., "blue" or "turquoise")
180    or a CSS3 RGB/RGBA color value in hex or functional notation (e.g., "#0000FF").
181    """
182
183    image: list[Image] = Field(default_factory=list)
184    """Specifies one or more images associated with the event."""
185
186    conference: list[Conference] = Field(default_factory=list)
187    """Specifies one or more conferences associated with the event."""
188
189    location: Optional[str] = None
190    """Defines the intended venue for the activity defined by this event."""
191
192    organizer: Optional[CalAddress] = None
193    """The organizer of a group-scheduled calendar entity."""
194
195    priority: Optional[Priority] = None
196    """Defines the relative priority of the calendar event."""
197
198    recurrence_id: Optional[RecurrenceId] = Field(alias="recurrence-id", default=None)
199    """Defines a specific instance of a recurring event.
200
201    The full range of calendar events specified by a recurrence set is referenced
202    by referring to just the uid. The `recurrence_id` allows reference of an individual
203    instance within the recurrence set.
204    """
205
206    related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list)
207    """Used to represent a relationship or reference between events."""
208
209    related: list[str] = Field(default_factory=list)
210    """Unused and will be deleted in a future release"""
211
212    resources: list[str] = Field(default_factory=list)
213    """Defines the equipment or resources anticipated for the calendar event."""
214
215    rrule: Optional[Recur] = None
216    """A recurrence rule specification.
217
218    Defines a rule for specifying a repeated event. The recurrence set is the complete
219    set of recurrence instances for a calendar component (based on rrule, rdate, exdate).
220    The recurrence set is generated by gathering the rrule and rdate properties then
221    excluding any times specified by exdate. The recurrence is generated with the dtstart
222    property defining the first instance of the recurrence set.
223
224    Typically a dtstart should be specified with a date local time and timezone to make
225    sure all instances have the same start time regardless of time zone changing.
226    """
227
228    rdate: Annotated[
229        list[Union[datetime.date, datetime.datetime, Period]],
230        BeforeValidator(parse_rdate_list),
231    ] = Field(default_factory=list)
232    """Defines the list of date/time values for recurring events.
233
234    Can appear along with the rrule property to define a set of repeating occurrences of the
235    event. The recurrence set is the complete set of recurrence instances for a calendar component
236    (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule
237    and rdate properties then excluding any times specified by exdate.
238    """
239
240    exdate: Annotated[
241        list[Union[datetime.date, datetime.datetime]],
242        BeforeValidator(parse_date_and_datetime_list),
243    ] = Field(default_factory=list)
244    """Defines the list of exceptions for recurring events.
245
246    The exception dates are used in computing the recurrence set. The recurrence set is
247    the complete set of recurrence instances for a calendar component (based on rrule, rdate,
248    exdate). The recurrence set is generated by gathering the rrule and rdate properties
249    then excluding any times specified by exdate.
250    """
251
252    request_status: list[RequestStatus] = Field(
253        alias="request-status",
254        default_factory=list,
255    )
256
257    sequence: Optional[int] = None
258    """The revision sequence number in the calendar component.
259
260    When an event is created, its sequence number is 0. It is monotonically incremented
261    by the organizer's calendar user agent every time a significant revision is made to
262    the calendar event.
263    """
264
265    status: Optional[EventStatus] = None
266    """Defines the overall status or confirmation of the event.
267
268    In a group-scheduled calendar, used by the organizer to provide a confirmation
269    of the event to attendees.
270    """
271
272    transparency: Optional[str] = Field(alias="transp", default=None)
273    """Defines whether or not an event is transparent to busy time searches."""
274
275    url: Optional[Uri] = None
276    """Defines a url associated with the event.
277
278    May convey a location where a more dynamic rendition of the calendar event
279    information associated with the event can be found.
280    """
281
282    attach: list[Attachment] = Field(default_factory=list)
283    """Associate a document object with the event."""
284
285    # Unknown or unsupported properties
286    extras: list[ExtraProperty] = Field(default_factory=list)
287
288    alarm: list[Alarm] = Field(alias="valarm", default_factory=list)
289    """A grouping of reminder alarms for the event."""
290
291    def __init__(self, **data: Any) -> None:
292        """Initialize a Calendar Event.
293
294        This method accepts keyword args with field names on the Calendar such as `summary`,
295        `start`, `end`, `description`, etc.
296        """
297        if "start" in data:
298            data["dtstart"] = data.pop("start")
299        if "end" in data:
300            data["dtend"] = data.pop("end")
301        super().__init__(**data)
302
303    @property
304    def start(self) -> datetime.datetime | datetime.date:
305        """Return the start time for the event."""
306        if self.dtstart is None:
307            raise AttributeError(
308                "Event.start accessed before dtstart was set; "
309                "ensure the event was fully validated before use."
310            )
311        return self.dtstart
312
313    @property
314    def end(self) -> datetime.datetime | datetime.date:
315        """Return the end time for the event."""
316        if self.duration:
317            return self.start + self.duration
318        if self.dtend:
319            return self.dtend
320
321        if isinstance(self.start, datetime.datetime):
322            return self.start
323        return self.start + datetime.timedelta(days=1)
324
325    @property
326    def start_datetime(self) -> datetime.datetime:
327        """Return the events start as a datetime in UTC"""
328        return normalize_datetime(self.start).astimezone(datetime.timezone.utc)
329
330    @property
331    def end_datetime(self) -> datetime.datetime:
332        """Return the events end as a datetime in UTC."""
333        return normalize_datetime(self.end).astimezone(datetime.timezone.utc)
334
335    @property
336    def computed_duration(self) -> datetime.timedelta:
337        """Return the event duration."""
338        if self.duration is not None:
339            return self.duration
340        return self.end - self.start
341
342    @property
343    def timespan(self) -> Timespan:
344        """Return a timespan representing the event start and end."""
345        return Timespan.of(self.start, self.end)
346
347    def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan:
348        """Return a timespan representing the event start and end."""
349        return Timespan.of(
350            normalize_datetime(self.start, tzinfo), normalize_datetime(self.end, tzinfo)
351        )
352
353    def starts_within(self, other: "Event") -> bool:
354        """Return True if this event starts while the other event is active."""
355        return self.timespan.starts_within(other.timespan)
356
357    def ends_within(self, other: "Event") -> bool:
358        """Return True if this event ends while the other event is active."""
359        return self.timespan.ends_within(other.timespan)
360
361    def intersects(self, other: "Event") -> bool:
362        """Return True if this event overlaps with the other event."""
363        return self.timespan.intersects(other.timespan)
364
365    def includes(self, other: "Event") -> bool:
366        """Return True if the other event starts and ends within this event."""
367        return self.timespan.includes(other.timespan)
368
369    def is_included_in(self, other: "Event") -> bool:
370        """Return True if this event starts and ends within the other event."""
371        return self.timespan.is_included_in(other.timespan)
372
373    def __lt__(self, other: Any) -> bool:
374        if not isinstance(other, Event):
375            return NotImplemented
376        return self.timespan < other.timespan
377
378    def __gt__(self, other: Any) -> bool:
379        if not isinstance(other, Event):
380            return NotImplemented
381        return self.timespan > other.timespan
382
383    def __le__(self, other: Any) -> bool:
384        if not isinstance(other, Event):
385            return NotImplemented
386        return self.timespan <= other.timespan
387
388    def __ge__(self, other: Any) -> bool:
389        if not isinstance(other, Event):
390            return NotImplemented
391        return self.timespan >= other.timespan
392
393    @property
394    def recurring(self) -> bool:
395        """Return true if this event is recurring.
396
397        A recurring event is typically evaluated specially on the timeline. The
398        data model has a single event, but the timeline evaluates the recurrence
399        to expand and copy the event to multiple places on the timeline
400        using `as_rrule`.
401        """
402        if self.rrule or self.rdate:
403            return True
404        return False
405
406    def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None:
407        """Return an iterable containing the occurrences of a recurring event.
408
409        A recurring event is typically evaluated specially on the timeline. The
410        data model has a single event, but the timeline evaluates the recurrence
411        to expand and copy the event to multiple places on the timeline.
412
413        This is only valid for events where `recurring` is True.
414        """
415        return as_rrule(self.rrule, self.rdate, self.exdate, self.dtstart)
416
417    @model_validator(mode="before")
418    @classmethod
419    def _inspect_date_types(cls, values: dict[str, Any]) -> dict[str, Any]:
420        """Debug the date and date/time values of the event."""
421        dtstart = values.get("dtstart")
422        dtend = values.get("dtend")
423        if not dtstart or not dtend:
424            return values
425        _LOGGER.debug("Found initial values dtstart=%s, dtend=%s", dtstart, dtend)
426        return values
427
428    _validate_until_dtstart = model_validator(mode="after")(validate_until_dtstart)
429    _validate_recurrence_dates = model_validator(mode="after")(
430        validate_recurrence_dates
431    )
432
433    @model_validator(mode="after")
434    def _validate_date_types(self) -> Self:
435        """Validate that start and end values are the same date or datetime type."""
436        dtstart = self.dtstart
437        dtend = self.dtend
438
439        if not dtstart or not dtend:
440            return self
441        if isinstance(dtstart, datetime.datetime):
442            if not isinstance(dtend, datetime.datetime):
443                _LOGGER.debug("Unexpected data types for values: %s", self)
444                raise ValueError(
445                    f"Unexpected dtstart value '{dtstart}' was datetime but "
446                    f"dtend value '{dtend}' was not datetime"
447                )
448        elif isinstance(dtstart, datetime.date):
449            if isinstance(dtend, datetime.datetime):
450                raise ValueError(
451                    f"Unexpected dtstart value '{dtstart}' was date but "
452                    f"dtend value '{dtend}' was datetime"
453                )
454        return self
455
456    @model_validator(mode="after")
457    def _validate_datetime_timezone(self) -> Self:
458        """Validate that start and end values have the same timezone information."""
459        if (
460            not (dtstart := self.dtstart)
461            or not (dtend := self.dtend)
462            or not isinstance(dtstart, datetime.datetime)
463            or not isinstance(dtend, datetime.datetime)
464        ):
465            return self
466        if dtstart.tzinfo is None and dtend.tzinfo is not None:
467            raise ValueError(
468                f"Expected end datetime value in localtime but was {dtend}"
469            )
470        if dtstart.tzinfo is not None and dtend.tzinfo is None:
471            raise ValueError(f"Expected end datetime with timezone but was {dtend}")
472        return self
473
474    @model_validator(mode="after")
475    def _validate_one_end_or_duration(self) -> Self:
476        """Validate that only one of duration or end date may be set."""
477        if self.dtend and self.duration:
478            if duration_dtend_compat.is_duration_dtend_compat_enabled():
479                # RFC 5545 3.6.1 forbids specifying both DTEND and DURATION,
480                # but some real-world generators emit both anyway (often
481                # redundantly). Prefer the more explicit DTEND value and
482                # drop DURATION rather than failing to parse.
483                _LOGGER.warning(
484                    "Event has both DTEND (%s) and DURATION (%s) set; "
485                    "dropping DURATION per compat mode",
486                    self.dtend,
487                    self.duration,
488                )
489                self.duration = None
490            else:
491                raise ValueError("Only one of dtend or duration may be set.")
492        return self
493
494    @model_validator(mode="after")
495    def _validate_same_day_dtend(self) -> Self:
496        """Fix same-day DTEND for all-day events when compat mode is enabled."""
497        if same_day_dtend_compat.is_same_day_dtend_compat_enabled():
498            if isinstance(self.dtstart, datetime.date) and not isinstance(
499                self.dtstart, datetime.datetime
500            ):
501                if self.dtend and self.dtend == self.dtstart:
502                    self.dtend = self.dtstart + datetime.timedelta(days=1)
503        return self
504
505    _validate_duration_unit = model_validator(mode="after")(validate_duration_unit)
506
507    serialize_fields = field_serializer("*")(serialize_field)  # type: ignore[pydantic-field]
class Event(ical.component.ComponentModel):
 81class Event(ComponentModel):
 82    """A single event on a calendar.
 83
 84    Can either be for a specific day, or with a start time and duration/end time.
 85
 86    The dtstamp and uid functions have factory methods invoked with a lambda to facilitate
 87    mocking in unit tests.
 88
 89
 90    Example:
 91    ```python
 92    import datetime
 93    from ical.event import Event
 94
 95    event = Event(
 96        dtstart=datetime.datetime(2022, 8, 31, 7, 00, 00),
 97        dtend=datetime.datetime(2022, 8, 31, 7, 30, 00),
 98        summary="Morning exercise",
 99    )
100    print("The event duration is: ", event.computed_duration)
101    ```
102
103    An Event is a pydantic model, so all properties of a pydantic model apply here to such as
104    the constructor arguments, properties to return the model as a dictionary or json, as well
105    as other parsing methods.
106    """
107
108    dtstamp: Annotated[
109        Union[datetime.date, datetime.datetime],
110        BeforeValidator(parse_date_and_datetime),
111    ] = Field(default_factory=lambda: dtstamp_factory())
112    """Specifies the date and time the event was created."""
113
114    uid: str = Field(default_factory=lambda: uid_factory())
115    """A globally unique identifier for the event."""
116
117    # Has an alias of 'start'
118    dtstart: Annotated[
119        Union[datetime.date, datetime.datetime, None],
120        BeforeValidator(parse_date_and_datetime),
121    ] = Field(default=None)
122    """The start time or start day of the event."""
123
124    # Has an alias of 'end'
125    dtend: Annotated[
126        Union[datetime.date, datetime.datetime, None],
127        BeforeValidator(parse_date_and_datetime),
128    ] = None
129    """The end time or end day of the event.
130
131    This may be specified as an explicit date. Alternatively, a duration
132    can be used instead.
133    """
134
135    duration: Optional[datetime.timedelta] = None
136    """The duration of the event as an alternative to an explicit end date/time."""
137
138    summary: Optional[str] = None
139    """Defines a short summary or subject for the event."""
140
141    attendees: list[CalAddress] = Field(alias="attendee", default_factory=list)
142    """Specifies participants in a group-scheduled calendar."""
143
144    categories: list[str] = Field(default_factory=list)
145    """Defines the categories for an event.
146
147    Specifies a category or subtype. Can be useful for searching for a particular
148    type of event.
149    """
150
151    classification: Optional[Classification] = Field(alias="class", default=None)
152    """An access classification for a calendar event.
153
154    This provides a method of capturing the scope of access of a calendar, in
155    conjunction with an access control system.
156    """
157
158    comment: list[str] = Field(default_factory=list)
159    """Specifies a comment to the calendar user."""
160
161    contacts: list[str] = Field(alias="contact", default_factory=list)
162    """Contact information associated with the event."""
163
164    created: Optional[datetime.datetime] = None
165    """The date and time the event information was created."""
166
167    description: Optional[str] = None
168    """A more complete description of the event than provided by the summary."""
169
170    geo: Optional[Geo] = None
171    """Specifies a latitude and longitude global position for the event activity."""
172
173    last_modified: Optional[datetime.datetime] = Field(
174        alias="last-modified", default=None
175    )
176
177    color: Optional[str] = None
178    """Specifies a color associated with the event.
179
180    The value MUST be a case-insensitive color name defined in CSS3-Color (e.g., "blue" or "turquoise")
181    or a CSS3 RGB/RGBA color value in hex or functional notation (e.g., "#0000FF").
182    """
183
184    image: list[Image] = Field(default_factory=list)
185    """Specifies one or more images associated with the event."""
186
187    conference: list[Conference] = Field(default_factory=list)
188    """Specifies one or more conferences associated with the event."""
189
190    location: Optional[str] = None
191    """Defines the intended venue for the activity defined by this event."""
192
193    organizer: Optional[CalAddress] = None
194    """The organizer of a group-scheduled calendar entity."""
195
196    priority: Optional[Priority] = None
197    """Defines the relative priority of the calendar event."""
198
199    recurrence_id: Optional[RecurrenceId] = Field(alias="recurrence-id", default=None)
200    """Defines a specific instance of a recurring event.
201
202    The full range of calendar events specified by a recurrence set is referenced
203    by referring to just the uid. The `recurrence_id` allows reference of an individual
204    instance within the recurrence set.
205    """
206
207    related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list)
208    """Used to represent a relationship or reference between events."""
209
210    related: list[str] = Field(default_factory=list)
211    """Unused and will be deleted in a future release"""
212
213    resources: list[str] = Field(default_factory=list)
214    """Defines the equipment or resources anticipated for the calendar event."""
215
216    rrule: Optional[Recur] = None
217    """A recurrence rule specification.
218
219    Defines a rule for specifying a repeated event. The recurrence set is the complete
220    set of recurrence instances for a calendar component (based on rrule, rdate, exdate).
221    The recurrence set is generated by gathering the rrule and rdate properties then
222    excluding any times specified by exdate. The recurrence is generated with the dtstart
223    property defining the first instance of the recurrence set.
224
225    Typically a dtstart should be specified with a date local time and timezone to make
226    sure all instances have the same start time regardless of time zone changing.
227    """
228
229    rdate: Annotated[
230        list[Union[datetime.date, datetime.datetime, Period]],
231        BeforeValidator(parse_rdate_list),
232    ] = Field(default_factory=list)
233    """Defines the list of date/time values for recurring events.
234
235    Can appear along with the rrule property to define a set of repeating occurrences of the
236    event. The recurrence set is the complete set of recurrence instances for a calendar component
237    (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule
238    and rdate properties then excluding any times specified by exdate.
239    """
240
241    exdate: Annotated[
242        list[Union[datetime.date, datetime.datetime]],
243        BeforeValidator(parse_date_and_datetime_list),
244    ] = Field(default_factory=list)
245    """Defines the list of exceptions for recurring events.
246
247    The exception dates are used in computing the recurrence set. The recurrence set is
248    the complete set of recurrence instances for a calendar component (based on rrule, rdate,
249    exdate). The recurrence set is generated by gathering the rrule and rdate properties
250    then excluding any times specified by exdate.
251    """
252
253    request_status: list[RequestStatus] = Field(
254        alias="request-status",
255        default_factory=list,
256    )
257
258    sequence: Optional[int] = None
259    """The revision sequence number in the calendar component.
260
261    When an event is created, its sequence number is 0. It is monotonically incremented
262    by the organizer's calendar user agent every time a significant revision is made to
263    the calendar event.
264    """
265
266    status: Optional[EventStatus] = None
267    """Defines the overall status or confirmation of the event.
268
269    In a group-scheduled calendar, used by the organizer to provide a confirmation
270    of the event to attendees.
271    """
272
273    transparency: Optional[str] = Field(alias="transp", default=None)
274    """Defines whether or not an event is transparent to busy time searches."""
275
276    url: Optional[Uri] = None
277    """Defines a url associated with the event.
278
279    May convey a location where a more dynamic rendition of the calendar event
280    information associated with the event can be found.
281    """
282
283    attach: list[Attachment] = Field(default_factory=list)
284    """Associate a document object with the event."""
285
286    # Unknown or unsupported properties
287    extras: list[ExtraProperty] = Field(default_factory=list)
288
289    alarm: list[Alarm] = Field(alias="valarm", default_factory=list)
290    """A grouping of reminder alarms for the event."""
291
292    def __init__(self, **data: Any) -> None:
293        """Initialize a Calendar Event.
294
295        This method accepts keyword args with field names on the Calendar such as `summary`,
296        `start`, `end`, `description`, etc.
297        """
298        if "start" in data:
299            data["dtstart"] = data.pop("start")
300        if "end" in data:
301            data["dtend"] = data.pop("end")
302        super().__init__(**data)
303
304    @property
305    def start(self) -> datetime.datetime | datetime.date:
306        """Return the start time for the event."""
307        if self.dtstart is None:
308            raise AttributeError(
309                "Event.start accessed before dtstart was set; "
310                "ensure the event was fully validated before use."
311            )
312        return self.dtstart
313
314    @property
315    def end(self) -> datetime.datetime | datetime.date:
316        """Return the end time for the event."""
317        if self.duration:
318            return self.start + self.duration
319        if self.dtend:
320            return self.dtend
321
322        if isinstance(self.start, datetime.datetime):
323            return self.start
324        return self.start + datetime.timedelta(days=1)
325
326    @property
327    def start_datetime(self) -> datetime.datetime:
328        """Return the events start as a datetime in UTC"""
329        return normalize_datetime(self.start).astimezone(datetime.timezone.utc)
330
331    @property
332    def end_datetime(self) -> datetime.datetime:
333        """Return the events end as a datetime in UTC."""
334        return normalize_datetime(self.end).astimezone(datetime.timezone.utc)
335
336    @property
337    def computed_duration(self) -> datetime.timedelta:
338        """Return the event duration."""
339        if self.duration is not None:
340            return self.duration
341        return self.end - self.start
342
343    @property
344    def timespan(self) -> Timespan:
345        """Return a timespan representing the event start and end."""
346        return Timespan.of(self.start, self.end)
347
348    def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan:
349        """Return a timespan representing the event start and end."""
350        return Timespan.of(
351            normalize_datetime(self.start, tzinfo), normalize_datetime(self.end, tzinfo)
352        )
353
354    def starts_within(self, other: "Event") -> bool:
355        """Return True if this event starts while the other event is active."""
356        return self.timespan.starts_within(other.timespan)
357
358    def ends_within(self, other: "Event") -> bool:
359        """Return True if this event ends while the other event is active."""
360        return self.timespan.ends_within(other.timespan)
361
362    def intersects(self, other: "Event") -> bool:
363        """Return True if this event overlaps with the other event."""
364        return self.timespan.intersects(other.timespan)
365
366    def includes(self, other: "Event") -> bool:
367        """Return True if the other event starts and ends within this event."""
368        return self.timespan.includes(other.timespan)
369
370    def is_included_in(self, other: "Event") -> bool:
371        """Return True if this event starts and ends within the other event."""
372        return self.timespan.is_included_in(other.timespan)
373
374    def __lt__(self, other: Any) -> bool:
375        if not isinstance(other, Event):
376            return NotImplemented
377        return self.timespan < other.timespan
378
379    def __gt__(self, other: Any) -> bool:
380        if not isinstance(other, Event):
381            return NotImplemented
382        return self.timespan > other.timespan
383
384    def __le__(self, other: Any) -> bool:
385        if not isinstance(other, Event):
386            return NotImplemented
387        return self.timespan <= other.timespan
388
389    def __ge__(self, other: Any) -> bool:
390        if not isinstance(other, Event):
391            return NotImplemented
392        return self.timespan >= other.timespan
393
394    @property
395    def recurring(self) -> bool:
396        """Return true if this event is recurring.
397
398        A recurring event is typically evaluated specially on the timeline. The
399        data model has a single event, but the timeline evaluates the recurrence
400        to expand and copy the event to multiple places on the timeline
401        using `as_rrule`.
402        """
403        if self.rrule or self.rdate:
404            return True
405        return False
406
407    def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None:
408        """Return an iterable containing the occurrences of a recurring event.
409
410        A recurring event is typically evaluated specially on the timeline. The
411        data model has a single event, but the timeline evaluates the recurrence
412        to expand and copy the event to multiple places on the timeline.
413
414        This is only valid for events where `recurring` is True.
415        """
416        return as_rrule(self.rrule, self.rdate, self.exdate, self.dtstart)
417
418    @model_validator(mode="before")
419    @classmethod
420    def _inspect_date_types(cls, values: dict[str, Any]) -> dict[str, Any]:
421        """Debug the date and date/time values of the event."""
422        dtstart = values.get("dtstart")
423        dtend = values.get("dtend")
424        if not dtstart or not dtend:
425            return values
426        _LOGGER.debug("Found initial values dtstart=%s, dtend=%s", dtstart, dtend)
427        return values
428
429    _validate_until_dtstart = model_validator(mode="after")(validate_until_dtstart)
430    _validate_recurrence_dates = model_validator(mode="after")(
431        validate_recurrence_dates
432    )
433
434    @model_validator(mode="after")
435    def _validate_date_types(self) -> Self:
436        """Validate that start and end values are the same date or datetime type."""
437        dtstart = self.dtstart
438        dtend = self.dtend
439
440        if not dtstart or not dtend:
441            return self
442        if isinstance(dtstart, datetime.datetime):
443            if not isinstance(dtend, datetime.datetime):
444                _LOGGER.debug("Unexpected data types for values: %s", self)
445                raise ValueError(
446                    f"Unexpected dtstart value '{dtstart}' was datetime but "
447                    f"dtend value '{dtend}' was not datetime"
448                )
449        elif isinstance(dtstart, datetime.date):
450            if isinstance(dtend, datetime.datetime):
451                raise ValueError(
452                    f"Unexpected dtstart value '{dtstart}' was date but "
453                    f"dtend value '{dtend}' was datetime"
454                )
455        return self
456
457    @model_validator(mode="after")
458    def _validate_datetime_timezone(self) -> Self:
459        """Validate that start and end values have the same timezone information."""
460        if (
461            not (dtstart := self.dtstart)
462            or not (dtend := self.dtend)
463            or not isinstance(dtstart, datetime.datetime)
464            or not isinstance(dtend, datetime.datetime)
465        ):
466            return self
467        if dtstart.tzinfo is None and dtend.tzinfo is not None:
468            raise ValueError(
469                f"Expected end datetime value in localtime but was {dtend}"
470            )
471        if dtstart.tzinfo is not None and dtend.tzinfo is None:
472            raise ValueError(f"Expected end datetime with timezone but was {dtend}")
473        return self
474
475    @model_validator(mode="after")
476    def _validate_one_end_or_duration(self) -> Self:
477        """Validate that only one of duration or end date may be set."""
478        if self.dtend and self.duration:
479            if duration_dtend_compat.is_duration_dtend_compat_enabled():
480                # RFC 5545 3.6.1 forbids specifying both DTEND and DURATION,
481                # but some real-world generators emit both anyway (often
482                # redundantly). Prefer the more explicit DTEND value and
483                # drop DURATION rather than failing to parse.
484                _LOGGER.warning(
485                    "Event has both DTEND (%s) and DURATION (%s) set; "
486                    "dropping DURATION per compat mode",
487                    self.dtend,
488                    self.duration,
489                )
490                self.duration = None
491            else:
492                raise ValueError("Only one of dtend or duration may be set.")
493        return self
494
495    @model_validator(mode="after")
496    def _validate_same_day_dtend(self) -> Self:
497        """Fix same-day DTEND for all-day events when compat mode is enabled."""
498        if same_day_dtend_compat.is_same_day_dtend_compat_enabled():
499            if isinstance(self.dtstart, datetime.date) and not isinstance(
500                self.dtstart, datetime.datetime
501            ):
502                if self.dtend and self.dtend == self.dtstart:
503                    self.dtend = self.dtstart + datetime.timedelta(days=1)
504        return self
505
506    _validate_duration_unit = model_validator(mode="after")(validate_duration_unit)
507
508    serialize_fields = field_serializer("*")(serialize_field)  # type: ignore[pydantic-field]

A single event on a calendar.

Can either be for a specific day, or with a start time and duration/end time.

The dtstamp and uid functions have factory methods invoked with a lambda to facilitate mocking in unit tests.

Example:

import datetime
from ical.event import Event

event = Event(
    dtstart=datetime.datetime(2022, 8, 31, 7, 00, 00),
    dtend=datetime.datetime(2022, 8, 31, 7, 30, 00),
    summary="Morning exercise",
)
print("The event duration is: ", event.computed_duration)

An Event is a pydantic model, so all properties of a pydantic model apply here to such as the constructor arguments, properties to return the model as a dictionary or json, as well as other parsing methods.

dtstamp: Annotated[Union[datetime.date, datetime.datetime], BeforeValidator(func=<function parse_date_and_datetime at 0x7f61ac97e700>, json_schema_input_type=PydanticUndefined)] = PydanticUndefined

Specifies the date and time the event was created.

uid: str = PydanticUndefined

A globally unique identifier for the event.

dtstart: Annotated[Union[datetime.date, datetime.datetime, NoneType], BeforeValidator(func=<function parse_date_and_datetime at 0x7f61ac97e700>, json_schema_input_type=PydanticUndefined)] = None

The start time or start day of the event.

dtend: Annotated[Union[datetime.date, datetime.datetime, NoneType], BeforeValidator(func=<function parse_date_and_datetime at 0x7f61ac97e700>, json_schema_input_type=PydanticUndefined)] = None

The end time or end day of the event.

This may be specified as an explicit date. Alternatively, a duration can be used instead.

duration: Optional[datetime.timedelta] = None

The duration of the event as an alternative to an explicit end date/time.

summary: Optional[str] = None

Defines a short summary or subject for the event.

attendees: list[ical.types.CalAddress] = PydanticUndefined

Specifies participants in a group-scheduled calendar.

categories: list[str] = PydanticUndefined

Defines the categories for an event.

Specifies a category or subtype. Can be useful for searching for a particular type of event.

classification: Optional[ical.types.Classification] = None

An access classification for a calendar event.

This provides a method of capturing the scope of access of a calendar, in conjunction with an access control system.

comment: list[str] = PydanticUndefined

Specifies a comment to the calendar user.

contacts: list[str] = PydanticUndefined

Contact information associated with the event.

created: Optional[datetime.datetime] = None

The date and time the event information was created.

description: Optional[str] = None

A more complete description of the event than provided by the summary.

geo: Optional[ical.types.Geo] = None

Specifies a latitude and longitude global position for the event activity.

last_modified: Optional[datetime.datetime] = None
color: Optional[str] = None

Specifies a color associated with the event.

The value MUST be a case-insensitive color name defined in CSS3-Color (e.g., "blue" or "turquoise") or a CSS3 RGB/RGBA color value in hex or functional notation (e.g., "#0000FF").

image: list[ical.types.Image] = PydanticUndefined

Specifies one or more images associated with the event.

conference: list[ical.types.Conference] = PydanticUndefined

Specifies one or more conferences associated with the event.

location: Optional[str] = None

Defines the intended venue for the activity defined by this event.

organizer: Optional[ical.types.CalAddress] = None

The organizer of a group-scheduled calendar entity.

priority: Optional[ical.types.Priority] = None

Defines the relative priority of the calendar event.

recurrence_id: Optional[ical.types.RecurrenceId] = None

Defines a specific instance of a recurring event.

The full range of calendar events specified by a recurrence set is referenced by referring to just the uid. The recurrence_id allows reference of an individual instance within the recurrence set.

related_to: list[ical.types.RelatedTo] = PydanticUndefined

Used to represent a relationship or reference between events.

related: list[str] = PydanticUndefined

Unused and will be deleted in a future release

resources: list[str] = PydanticUndefined

Defines the equipment or resources anticipated for the calendar event.

rrule: Optional[ical.types.Recur] = None

A recurrence rule specification.

Defines a rule for specifying a repeated event. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate. The recurrence is generated with the dtstart property defining the first instance of the recurrence set.

Typically a dtstart should be specified with a date local time and timezone to make sure all instances have the same start time regardless of time zone changing.

rdate: Annotated[list[Union[datetime.date, datetime.datetime, ical.types.Period]], BeforeValidator(func=<function parse_rdate_list at 0x7f61ac97e840>, json_schema_input_type=PydanticUndefined)] = PydanticUndefined

Defines the list of date/time values for recurring events.

Can appear along with the rrule property to define a set of repeating occurrences of the event. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate.

exdate: Annotated[list[Union[datetime.date, datetime.datetime]], BeforeValidator(func=<function parse_date_and_datetime_list at 0x7f61ac97e7a0>, json_schema_input_type=PydanticUndefined)] = PydanticUndefined

Defines the list of exceptions for recurring events.

The exception dates are used in computing the recurrence set. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate.

request_status: list[ical.types.RequestStatus] = PydanticUndefined
sequence: Optional[int] = None

The revision sequence number in the calendar component.

When an event is created, its sequence number is 0. It is monotonically incremented by the organizer's calendar user agent every time a significant revision is made to the calendar event.

status: Optional[EventStatus] = None

Defines the overall status or confirmation of the event.

In a group-scheduled calendar, used by the organizer to provide a confirmation of the event to attendees.

transparency: Optional[str] = None

Defines whether or not an event is transparent to busy time searches.

url: Optional[ical.types.Uri] = None

Defines a url associated with the event.

May convey a location where a more dynamic rendition of the calendar event information associated with the event can be found.

attach: list[ical.types.Attachment] = PydanticUndefined

Associate a document object with the event.

extras: list[ical.types.ExtraProperty] = PydanticUndefined
alarm: list[ical.alarm.Alarm] = PydanticUndefined

A grouping of reminder alarms for the event.

start: datetime.datetime | datetime.date
304    @property
305    def start(self) -> datetime.datetime | datetime.date:
306        """Return the start time for the event."""
307        if self.dtstart is None:
308            raise AttributeError(
309                "Event.start accessed before dtstart was set; "
310                "ensure the event was fully validated before use."
311            )
312        return self.dtstart

Return the start time for the event.

end: datetime.datetime | datetime.date
314    @property
315    def end(self) -> datetime.datetime | datetime.date:
316        """Return the end time for the event."""
317        if self.duration:
318            return self.start + self.duration
319        if self.dtend:
320            return self.dtend
321
322        if isinstance(self.start, datetime.datetime):
323            return self.start
324        return self.start + datetime.timedelta(days=1)

Return the end time for the event.

start_datetime: datetime.datetime
326    @property
327    def start_datetime(self) -> datetime.datetime:
328        """Return the events start as a datetime in UTC"""
329        return normalize_datetime(self.start).astimezone(datetime.timezone.utc)

Return the events start as a datetime in UTC

end_datetime: datetime.datetime
331    @property
332    def end_datetime(self) -> datetime.datetime:
333        """Return the events end as a datetime in UTC."""
334        return normalize_datetime(self.end).astimezone(datetime.timezone.utc)

Return the events end as a datetime in UTC.

computed_duration: datetime.timedelta
336    @property
337    def computed_duration(self) -> datetime.timedelta:
338        """Return the event duration."""
339        if self.duration is not None:
340            return self.duration
341        return self.end - self.start

Return the event duration.

timespan: ical.timespan.Timespan
343    @property
344    def timespan(self) -> Timespan:
345        """Return a timespan representing the event start and end."""
346        return Timespan.of(self.start, self.end)

Return a timespan representing the event start and end.

def timespan_of(self, tzinfo: datetime.tzinfo) -> ical.timespan.Timespan:
348    def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan:
349        """Return a timespan representing the event start and end."""
350        return Timespan.of(
351            normalize_datetime(self.start, tzinfo), normalize_datetime(self.end, tzinfo)
352        )

Return a timespan representing the event start and end.

def starts_within(self, other: Event) -> bool:
354    def starts_within(self, other: "Event") -> bool:
355        """Return True if this event starts while the other event is active."""
356        return self.timespan.starts_within(other.timespan)

Return True if this event starts while the other event is active.

def ends_within(self, other: Event) -> bool:
358    def ends_within(self, other: "Event") -> bool:
359        """Return True if this event ends while the other event is active."""
360        return self.timespan.ends_within(other.timespan)

Return True if this event ends while the other event is active.

def intersects(self, other: Event) -> bool:
362    def intersects(self, other: "Event") -> bool:
363        """Return True if this event overlaps with the other event."""
364        return self.timespan.intersects(other.timespan)

Return True if this event overlaps with the other event.

def includes(self, other: Event) -> bool:
366    def includes(self, other: "Event") -> bool:
367        """Return True if the other event starts and ends within this event."""
368        return self.timespan.includes(other.timespan)

Return True if the other event starts and ends within this event.

def is_included_in(self, other: Event) -> bool:
370    def is_included_in(self, other: "Event") -> bool:
371        """Return True if this event starts and ends within the other event."""
372        return self.timespan.is_included_in(other.timespan)

Return True if this event starts and ends within the other event.

recurring: bool
394    @property
395    def recurring(self) -> bool:
396        """Return true if this event is recurring.
397
398        A recurring event is typically evaluated specially on the timeline. The
399        data model has a single event, but the timeline evaluates the recurrence
400        to expand and copy the event to multiple places on the timeline
401        using `as_rrule`.
402        """
403        if self.rrule or self.rdate:
404            return True
405        return False

Return true if this event is recurring.

A recurring event is typically evaluated specially on the timeline. The data model has a single event, but the timeline evaluates the recurrence to expand and copy the event to multiple places on the timeline using as_rrule.

def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None:
407    def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None:
408        """Return an iterable containing the occurrences of a recurring event.
409
410        A recurring event is typically evaluated specially on the timeline. The
411        data model has a single event, but the timeline evaluates the recurrence
412        to expand and copy the event to multiple places on the timeline.
413
414        This is only valid for events where `recurring` is True.
415        """
416        return as_rrule(self.rrule, self.rdate, self.exdate, self.dtstart)

Return an iterable containing the occurrences of a recurring event.

A recurring event is typically evaluated specially on the timeline. The data model has a single event, but the timeline evaluates the recurrence to expand and copy the event to multiple places on the timeline.

This is only valid for events where recurring is True.

def serialize_fields( self: pydantic.main.BaseModel, value: Any, info: pydantic_core.core_schema.SerializationInfo) -> Any:
293def serialize_field(self: BaseModel, value: Any, info: SerializationInfo) -> Any:
294    if not info.context or not info.context.get("ics"):
295        return value
296    if isinstance(value, list):
297        res = []
298        for val in value:
299            for base in val.__class__.__mro__[:-1]:
300                if (func := DATA_TYPE.encode_property_json.get(base)) is not None:
301                    res.append(func(val))
302                    break
303            else:
304                res.append(val)
305        return res
306
307    for base in value.__class__.__mro__[:-1]:
308        if (func := DATA_TYPE.encode_property_json.get(base)) is not None:
309            return func(value)
310    return value
class EventStatus(builtins.str, enum.Enum):
68class EventStatus(str, enum.Enum):
69    """Status or confirmation of the event set by the organizer."""
70
71    CONFIRMED = "CONFIRMED"
72    """Indicates event is definite."""
73
74    TENTATIVE = "TENTATIVE"
75    """Indicates event is tentative."""
76
77    CANCELLED = "CANCELLED"
78    """Indicates event was cancelled."""

Status or confirmation of the event set by the organizer.

CONFIRMED = <EventStatus.CONFIRMED: 'CONFIRMED'>

Indicates event is definite.

TENTATIVE = <EventStatus.TENTATIVE: 'TENTATIVE'>

Indicates event is tentative.

CANCELLED = <EventStatus.CANCELLED: 'CANCELLED'>

Indicates event was cancelled.