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

Return the start time for the event.

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

Return the end time for the event.

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

Return the events start as a datetime in UTC

end_datetime: datetime.datetime
335    @property
336    def end_datetime(self) -> datetime.datetime:
337        """Return the events end as a datetime in UTC."""
338        return normalize_datetime(self.end).astimezone(datetime.timezone.utc)

Return the events end as a datetime in UTC.

computed_duration: datetime.timedelta
340    @property
341    def computed_duration(self) -> datetime.timedelta:
342        """Return the event duration."""
343        if self.duration is not None:
344            return self.duration
345        return self.end - self.start

Return the event duration.

timespan: ical.timespan.Timespan
347    @property
348    def timespan(self) -> Timespan:
349        """Return a timespan representing the event start and end."""
350        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:
352    def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan:
353        """Return a timespan representing the event start and end."""
354        return Timespan.of(
355            normalize_datetime(self.start, tzinfo), normalize_datetime(self.end, tzinfo)
356        )

Return a timespan representing the event start and end.

def starts_within(self, other: Event) -> bool:
358    def starts_within(self, other: "Event") -> bool:
359        """Return True if this event starts while the other event is active."""
360        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:
362    def ends_within(self, other: "Event") -> bool:
363        """Return True if this event ends while the other event is active."""
364        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:
366    def intersects(self, other: "Event") -> bool:
367        """Return True if this event overlaps with the other event."""
368        return self.timespan.intersects(other.timespan)

Return True if this event overlaps with the other event.

def includes(self, other: Event) -> bool:
370    def includes(self, other: "Event") -> bool:
371        """Return True if the other event starts and ends within this event."""
372        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:
374    def is_included_in(self, other: "Event") -> bool:
375        """Return True if this event starts and ends within the other event."""
376        return self.timespan.is_included_in(other.timespan)

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

recurring: bool
398    @property
399    def recurring(self) -> bool:
400        """Return true if this event is recurring.
401
402        A recurring event is typically evaluated specially on the timeline. The
403        data model has a single event, but the timeline evaluates the recurrence
404        to expand and copy the event to multiple places on the timeline
405        using `as_rrule`.
406        """
407        if self.rrule or self.rdate:
408            return True
409        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:
411    def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None:
412        """Return an iterable containing the occurrences of a recurring event.
413
414        A recurring event is typically evaluated specially on the timeline. The
415        data model has a single event, but the timeline evaluates the recurrence
416        to expand and copy the event to multiple places on the timeline.
417
418        This is only valid for events where `recurring` is True.
419        """
420        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:
295def serialize_field(self: BaseModel, value: Any, info: SerializationInfo) -> Any:
296    if not info.context or not info.context.get("ics"):
297        return value
298    if isinstance(value, list):
299        res = []
300        for val in value:
301            for base in val.__class__.__mro__[:-1]:
302                if (func := DATA_TYPE.encode_property_json.get(base)) is not None:
303                    res.append(func(val))
304                    break
305            else:
306                res.append(val)
307        return res
308
309    for base in value.__class__.__mro__[:-1]:
310        if (func := DATA_TYPE.encode_property_json.get(base)) is not None:
311            return func(value)
312    return value
class EventStatus(builtins.str, enum.Enum):
72class EventStatus(str, enum.Enum):
73    """Status or confirmation of the event set by the organizer."""
74
75    CONFIRMED = "CONFIRMED"
76    """Indicates event is definite."""
77
78    TENTATIVE = "TENTATIVE"
79    """Indicates event is tentative."""
80
81    CANCELLED = "CANCELLED"
82    """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.