1""":rfc:`5545` iCalendar component."""
2
3from __future__ import annotations
4
5import uuid
6from datetime import timedelta
7from typing import TYPE_CHECKING, Literal, cast, overload
8
9from icalendar.attr import (
10 CONCEPTS_TYPE_SETTER,
11 LINKS_TYPE_SETTER,
12 RELATED_TO_TYPE_SETTER,
13 categories_property,
14 images_property,
15 multi_language_text_property,
16 single_string_property,
17 source_property,
18 uid_property,
19 url_property,
20)
21from icalendar.cal.component import Component
22from icalendar.cal.examples import get_example
23from icalendar.cal.timezone import Timezone
24from icalendar.error import IncompleteComponent
25from icalendar.parser.ical.calendar import CalendarIcalParser
26from icalendar.version import __version__
27
28if TYPE_CHECKING:
29 from collections.abc import Iterable, Sequence
30 from datetime import date, datetime
31 from pathlib import Path
32
33 from icalendar.cal import (
34 Availability,
35 Event,
36 FreeBusy,
37 Journal,
38 Todo,
39 )
40 from icalendar.compatibility import Self
41 from icalendar.parser.ical.component import ComponentIcalParser
42
43
44DEFAULT_PRODID = f"-//collective//icalendar//{__version__}//EN"
45
46
47class Calendar(Component):
48 """
49 The "VCALENDAR" object is a collection of calendar information.
50 This information can include a variety of components, such as
51 "VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY", "VTIMEZONE", or any
52 other type of calendar component.
53
54 Example:
55 Create a new Calendar:
56
57 >>> from icalendar import Calendar
58 >>> calendar = Calendar.new(name="My Calendar")
59 >>> print(calendar.calendar_name)
60 My Calendar
61
62 """
63
64 name = "VCALENDAR"
65 canonical_order = (
66 "VERSION",
67 "PRODID",
68 "CALSCALE",
69 "METHOD",
70 "DESCRIPTION",
71 "X-WR-CALDESC",
72 "NAME",
73 "X-WR-CALNAME",
74 )
75 required = (
76 "PRODID",
77 "VERSION",
78 )
79 singletons = (
80 "PRODID",
81 "VERSION",
82 "CALSCALE",
83 "METHOD",
84 "COLOR", # RFC 7986
85 )
86 multiple = (
87 "CATEGORIES", # RFC 7986
88 "DESCRIPTION", # RFC 7986
89 "NAME", # RFC 7986
90 )
91
92 @classmethod
93 def example(cls, name: str = "example") -> Calendar:
94 """Return the calendar example with the given name.
95
96 Example:
97
98 .. code-block:: pycon
99
100 >>> from icalendar import Calendar
101 >>> print(Calendar.example().to_ical().decode())
102 BEGIN:VCALENDAR
103 VERSION:2.0
104 PRODID:collective/icalendar
105 CALSCALE:GREGORIAN
106 METHOD:PUBLISH
107 X-WR-CALNAME:Holidays
108 X-WR-TIMEZONE:Etc/GMT
109 BEGIN:VEVENT
110 SUMMARY:New Year's Day
111 DTSTART:20220101
112 DTEND:20220101
113 DTSTAMP:20221108T080105Z
114 UID:636a0cc1dbd5a1667894465@icalendar
115 SEQUENCE:0
116 DESCRIPTION:Happy New Year!
117 STATUS:CONFIRMED
118 TRANSP:TRANSPARENT
119 END:VEVENT
120 BEGIN:VEVENT
121 SUMMARY:Orthodox Christmas
122 DTSTART:20220107
123 DTEND:20220107
124 UID:636a0cc1dbfd91667894465@icalendar
125 SEQUENCE:0
126 DESCRIPTION:It is Christmas again!
127 LOCATION:Russia
128 STATUS:CONFIRMED
129 TRANSP:TRANSPARENT
130 END:VEVENT
131 BEGIN:VEVENT
132 SUMMARY:International Women's Day
133 DTSTART:20220308
134 DTEND:20220308
135 UID:636a0cc1dc0f11667894465@icalendar
136 SEQUENCE:0
137 DESCRIPTION:May the feminine be honoured!
138 STATUS:CONFIRMED
139 TRANSP:TRANSPARENT
140 END:VEVENT
141 END:VCALENDAR
142 """
143 return cls.from_ical(get_example("calendars", name))
144
145 @classmethod
146 def _get_ical_parser(cls, st: str | bytes) -> ComponentIcalParser:
147 """Get the iCal parser for the given input string."""
148 return CalendarIcalParser(st, cls._get_component_factory(), cls.types_factory)
149
150 @overload
151 @classmethod
152 def from_ical(
153 cls, st: str | bytes | Path, multiple: Literal[False] = False
154 ) -> Calendar: ...
155
156 @overload
157 @classmethod
158 def from_ical(
159 cls, st: str | bytes | Path, multiple: Literal[True]
160 ) -> list[Calendar]: ...
161
162 @classmethod
163 def from_ical(
164 cls, st: str | bytes | Path, multiple: bool = False
165 ) -> Calendar | list[Calendar]:
166 """Parse iCalendar data into calendar instances.
167
168 Parameters:
169 st: iCalendar data as bytes or string, or a path to an iCalendar file.
170 multiple: If ``True``, returns a list of calendars.
171 If ``False``, returns a single calendar.
172
173 Returns:
174 Calendar or list of calendars.
175 """
176 return cast(
177 "Calendar | list[Calendar]", super().from_ical(st, multiple=multiple)
178 )
179
180 @property
181 def events(self) -> list[Event]:
182 """All event components in the calendar.
183
184 This is a shortcut to get all events.
185 Modifications do not change the calendar.
186 Use :meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
187
188 >>> from icalendar import Calendar
189 >>> calendar = Calendar.example()
190 >>> event = calendar.events[0]
191 >>> event.start
192 datetime.date(2022, 1, 1)
193 >>> print(event["SUMMARY"])
194 New Year's Day
195 """
196 return self.walk("VEVENT")
197
198 @property
199 def todos(self) -> list[Todo]:
200 """All todo components in the calendar.
201
202 This is a shortcut to get all todos.
203 Modifications do not change the calendar.
204 Use :meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
205 """
206 return self.walk("VTODO")
207
208 @property
209 def journals(self) -> list[Journal]:
210 """All journal components in the calendar.
211
212 This is a shortcut to get all journals.
213 Modifications do not change the calendar.
214 Use :meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
215 """
216 return self.walk("VJOURNAL")
217
218 @property
219 def availabilities(self) -> list[Availability]:
220 """All :class:`Availability` components in the calendar.
221
222 This is a shortcut to get all availabilities.
223 Modifications do not change the calendar.
224 Use :meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
225 """
226 return self.walk("VAVAILABILITY")
227
228 @property
229 def freebusy(self) -> list[FreeBusy]:
230 """All FreeBusy components in the calendar.
231
232 This is a shortcut to get all FreeBusy.
233 Modifications do not change the calendar.
234 Use :meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
235 """
236 return self.walk("VFREEBUSY")
237
238 def get_used_tzids(self) -> set[str]:
239 """The set of TZIDs in use.
240
241 This goes through the whole calendar to find all occurrences of
242 timezone information like the TZID parameter in all attributes.
243
244 >>> from icalendar import Calendar
245 >>> calendar = Calendar.example("timezone_rdate")
246 >>> calendar.get_used_tzids()
247 {'posix/Europe/Vaduz'}
248
249 Even if you use UTC, this will not show up.
250 """
251 result = set()
252 for _name, value in self.property_items(sorted=False):
253 if hasattr(value, "params"):
254 result.add(value.params.get("TZID"))
255 return result - {None}
256
257 def get_missing_tzids(self) -> set[str]:
258 """The set of missing timezone component tzids.
259
260 To create a :rfc:`5545` compatible calendar,
261 all of these timezones should be added.
262
263 UTC is excluded: per :rfc:`5545#section-3.2.19`, UTC datetimes use
264 the ``Z`` suffix and never require a VTIMEZONE component.
265 """
266 tzids = self.get_used_tzids() - {"UTC"}
267 for timezone in self.timezones:
268 # discard (not remove) — a VTIMEZONE may exist for a timezone not
269 # referenced by any event TZID (e.g. added by x-wr-timezone conversion)
270 tzids.discard(timezone.tz_name)
271 return tzids
272
273 @property
274 def timezones(self) -> list[Timezone]:
275 """Return the timezones components in this calendar.
276
277 >>> from icalendar import Calendar
278 >>> calendar = Calendar.example("pacific_fiji")
279 >>> [timezone.tz_name for timezone in calendar.timezones]
280 ['custom_Pacific/Fiji']
281
282 .. note::
283
284 This is a read-only property.
285 """
286 return self.walk("VTIMEZONE")
287
288 def add_missing_timezones(
289 self,
290 first_date: date = Timezone.DEFAULT_FIRST_DATE,
291 last_date: date = Timezone.DEFAULT_LAST_DATE,
292 ):
293 """Add all missing VTIMEZONE components.
294
295 This adds all the timezone components that are required.
296 VTIMEZONE components are inserted at the beginning of the calendar
297 to ensure they appear before other components that reference them.
298
299 .. note::
300
301 Timezones that are not known will not be added.
302
303 Parameters:
304 first_date: Earlier than anything that happens in the calendar.
305 last_date: Later than anything happening in the calendar.
306
307 >>> from icalendar import Calendar, Event
308 >>> from datetime import datetime
309 >>> from zoneinfo import ZoneInfo
310 >>> calendar = Calendar()
311 >>> event = Event()
312 >>> calendar.add_component(event)
313 >>> event.start = datetime(1990, 10, 11, 12, tzinfo=ZoneInfo("Europe/Berlin"))
314 >>> calendar.timezones
315 []
316 >>> calendar.add_missing_timezones()
317 >>> calendar.timezones[0].tz_name
318 'Europe/Berlin'
319 >>> calendar.get_missing_tzids() # check that all are added
320 set()
321 """
322 missing_tzids = self.get_missing_tzids()
323 if not missing_tzids:
324 return
325
326 existing_timezone_count = len(self.timezones)
327
328 for tzid in missing_tzids:
329 try:
330 timezone = Timezone.from_tzid(
331 tzid, first_date=first_date, last_date=last_date
332 )
333 except ValueError:
334 continue
335 self.subcomponents.insert(existing_timezone_count, timezone)
336 existing_timezone_count += 1
337
338 calendar_name = multi_language_text_property(
339 "NAME",
340 "X-WR-CALNAME",
341 """The display name of this calendar, per :rfc:`7986#section-5.1`.
342
343 Implements both the ``NAME`` property from :rfc:`7986#section-5.1` and the widely used
344 ``X-WR-CALNAME`` extension for broader client compatibility.
345
346 Multiple language variants can be stored by setting this property more than
347 once, each with a different ``LANGUAGE`` parameter value.
348
349 Example:
350 Set the name of the calendar.
351
352 .. code-block:: pycon
353
354 >>> from icalendar import Calendar
355 >>> calendar = Calendar()
356 >>> calendar.calendar_name = "My Calendar"
357 >>> print(calendar.to_ical().decode())
358 BEGIN:VCALENDAR
359 NAME:My Calendar
360 X-WR-CALNAME:My Calendar
361 END:VCALENDAR
362
363 """,
364 )
365
366 description = multi_language_text_property(
367 "DESCRIPTION",
368 "X-WR-CALDESC",
369 """A description of the calendar's content.
370
371 Implements both ``DESCRIPTION`` from :rfc:`5545#section-3.8.1.5` and
372 :rfc:`7986#section-5.2` and ``X-WR-CALDESC`` for broader calendar client
373 compatibility.
374
375 Multiple language variants can be stored by setting this property more than
376 once with different ``LANGUAGE`` parameter values.
377
378 Example:
379 Add a description to a calendar.
380
381 .. code-block:: pycon
382
383 >>> from icalendar import Calendar
384 >>> calendar = Calendar()
385 >>> calendar.description = "This is a calendar"
386 >>> print(calendar.to_ical().decode())
387 BEGIN:VCALENDAR
388 DESCRIPTION:This is a calendar
389 X-WR-CALDESC:This is a calendar
390 END:VCALENDAR
391
392 """,
393 )
394
395 color = single_string_property(
396 "COLOR",
397 """A CSS3 color name or value used to visually distinguish this calendar, per :rfc:`7986#section-5.9`.
398
399 Implements both ``COLOR`` from :rfc:`7986#section-5.9` and ``X-APPLE-CALENDAR-COLOR``.
400 The value is a case-insensitive CSS3 color name, for example, ``"turquoise"``, or
401 a hex code, for example, ``"#ffffff"``, drawn from the
402 `CSS3 color specification <https://www.w3.org/TR/css-color-3/>`_.
403
404 Since :rfc:`7986`, individual ``VEVENT``, ``VTODO``, and ``VJOURNAL``
405 subcomponents may also carry their own color.
406
407 Example:
408 .. code-block:: pycon
409
410 >>> from icalendar import Calendar
411 >>> calendar = Calendar()
412 >>> calendar.color = "black"
413 >>> print(calendar.to_ical().decode())
414 BEGIN:VCALENDAR
415 COLOR:black
416 END:VCALENDAR
417
418 """,
419 "X-APPLE-CALENDAR-COLOR",
420 )
421 categories = categories_property
422 uid = uid_property
423 prodid = single_string_property(
424 "PRODID",
425 """The product identifier for the software that created this iCalendar object.
426
427This property is defined in :rfc:`5545#section-3.7.3`.
428It's required exactly once per iCalendar object.
429
430The value should be a globally unique string. The conventional format is a
431Formal Public Identifier (FPI), for example, ``-//My Company//My Product//EN``, but any
432unique string is acceptable.
433
434Example:
435 Set a custom product identifier on a new calendar.
436
437 .. code-block:: pycon
438
439 >>> from icalendar import Calendar
440 >>> cal = Calendar()
441 >>> cal.prodid = "-//MyApp//MyCalendar//EN"
442 >>> str(cal.prodid)
443 '-//MyApp//MyCalendar//EN'
444
445See also:
446 :attr:`version`
447""",
448 )
449 version = single_string_property(
450 "VERSION",
451 """The iCalendar specification version required to interpret this object.
452
453This property is defined in :rfc:`5545#section-3.7.4`.
454It's required exactly once per calendar object.
455The value ``"2.0"`` indicates :rfc:`5545` compliance, which is the default used
456by this library. A range such as ``"1.0;2.0"`` may indicate minimum and maximum
457supported versions.
458
459Example:
460 .. code-block:: pycon
461
462 >>> from icalendar import Calendar
463 >>> cal = Calendar()
464 >>> cal.version = "2.0"
465 >>> str(cal.version)
466 '2.0'
467
468See also:
469 :attr:`prodid`
470""",
471 )
472
473 calscale = single_string_property(
474 "CALSCALE",
475 """The calendar scale for date and time values in this iCalendar object.
476
477This property is defined in :rfc:`5545#section-3.7.1`. The only value currently defined is
478``"GREGORIAN"`` (the default). When this property is absent, Gregorian is assumed.
479
480Per :rfc:`7529`, non-Gregorian calendar systems are expressed via ``RRULE``
481transformations rather than a different ``CALSCALE`` value. However, icalendar
482currently implements only the parsing of this value, not a transformation.
483
484Example:
485 .. code-block:: pycon
486
487 >>> from icalendar import Calendar
488 >>> cal = Calendar()
489 >>> cal.calscale
490 'GREGORIAN'
491
492 """,
493 default="GREGORIAN",
494 )
495 method = single_string_property(
496 "METHOD",
497 """The iTIP scheduling method associated with this calendar object, per :rfc:`5545#section-3.7.2`.
498
499When present, ``METHOD`` indicates that this object is part of a scheduling
500transaction, such as a meeting invitation or cancellation. Scheduling methods
501are defined by :rfc:`5546#section-1.4` (iTIP), with values such as ``"REQUEST"``,
502``"REPLY"``, ``"CANCEL"``, and ``"PUBLISH"``.
503
504When used inside a MIME message, this value must match the ``method`` parameter
505of the ``Content-Type`` header. If absent, the calendar is treated as a plain
506data snapshot with no scheduling semantics.
507
508Example:
509 .. code-block:: pycon
510
511 >>> from icalendar import Calendar
512 >>> cal = Calendar()
513 >>> cal.method = "REQUEST"
514 >>> str(cal.method)
515 'REQUEST'
516
517""",
518 )
519 url = url_property
520 source = source_property
521
522 @property
523 def refresh_interval(self) -> timedelta | None:
524 """A suggested minimum polling interval for fetching updates to this calendar, per :rfc:`7986#section-5.7`.
525
526 Calendar clients should not poll more frequently than this interval.
527 The value must be a positive duration.
528
529 Returns:
530 A :class:`~datetime.timedelta`, or ``None`` when not set.
531
532 Raises:
533 ValueError: When setting a non-positive (zero or negative) duration.
534 TypeError: When setting a value that is not a :class:`~datetime.timedelta` or ``None``.
535
536 Example:
537 .. code-block:: pycon
538
539 >>> from datetime import timedelta
540 >>> from icalendar import Calendar
541 >>> cal = Calendar()
542 >>> cal.refresh_interval = timedelta(hours=1)
543 >>> cal.refresh_interval
544 datetime.timedelta(seconds=3600)
545
546 """
547 refresh_interval = self.get("REFRESH-INTERVAL")
548 return refresh_interval.dt if refresh_interval else None
549
550 @refresh_interval.setter
551 def refresh_interval(self, value: timedelta | None):
552 """Set the REFRESH-INTERVAL."""
553 if not isinstance(value, timedelta) and value is not None:
554 raise TypeError(
555 "REFRESH-INTERVAL must be either a positive timedelta,"
556 " or None to delete it."
557 )
558 if value is not None and value.total_seconds() <= 0:
559 raise ValueError("REFRESH-INTERVAL must be a positive timedelta.")
560 if value is not None:
561 del self.refresh_interval
562 self.add("REFRESH-INTERVAL", value)
563 else:
564 del self.refresh_interval
565
566 @refresh_interval.deleter
567 def refresh_interval(self):
568 """Delete REFRESH-INTERVAL."""
569 self.pop("REFRESH-INTERVAL")
570
571 images = images_property
572
573 @classmethod
574 def new(
575 cls,
576 /,
577 calscale: str | None = None,
578 categories: Sequence[str] = (),
579 color: str | None = None,
580 concepts: CONCEPTS_TYPE_SETTER = None,
581 description: str | None = None,
582 language: str | None = None,
583 last_modified: date | datetime | None = None,
584 links: LINKS_TYPE_SETTER = None,
585 method: str | None = None,
586 name: str | None = None,
587 organization: str | None = None,
588 prodid: str | None = None,
589 refresh_interval: timedelta | None = None,
590 refids: list[str] | str | None = None,
591 related_to: RELATED_TO_TYPE_SETTER = None,
592 source: str | None = None,
593 subcomponents: Iterable[Component] | None = None,
594 uid: str | uuid.UUID | None = None,
595 url: str | None = None,
596 version: str = "2.0",
597 ) -> Self:
598 """Create a new Calendar.
599
600 This creates a new Calendar in accordance with :rfc:`5545` and :rfc:`7986`.
601
602 Parameters:
603 calscale: The :attr:`calscale` of the calendar.
604 categories: The :attr:`categories` of the calendar.
605 color: The :attr:`color` of the calendar.
606 concepts: The :attr:`~icalendar.cal.component.Component.concepts` of the calendar.
607 description: The :attr:`description` of the calendar.
608 language: The language for the calendar. Used to generate localized `prodid`.
609 last_modified: The :attr:`~icalendar.cal.component.Component.last_modified` of the calendar.
610 links: The :attr:`~icalendar.cal.component.Component.links` of the calendar.
611 method: The :attr:`method` of the calendar.
612 name: The :attr:`calendar_name` of the calendar.
613 organization: The organization name. Used to generate `prodid` if not provided.
614 prodid: The :attr:`prodid` of the component. If ``None`` and ``organization`` is provided,
615 generates a `prodid` in the format of "-//organization//name//language".
616 If ``None`` and ``organization`` is not provided, sets it to
617 :attr:`~icalendar.cal.calendar.DEFAULT_PRODID`.
618 refresh_interval: The :attr:`refresh_interval` of the calendar.
619 refids: :attr:`~icalendar.cal.component.Component.refids` of the calendar.
620 related_to: :attr:`~icalendar.cal.component.Component.related_to` of the calendar.
621 source: The :attr:`source` of the calendar.
622 subcomponents: The subcomponents of the calendar.
623 uid: The :attr:`uid` of the calendar.
624 If None, this is set to a new :func:`uuid.uuid4`.
625 url: The :attr:`url` of the calendar.
626 version: The :attr:`version` of the calendar.
627
628 Returns:
629 :class:`Calendar`
630
631 Raises:
632 ~error.InvalidCalendar: If the content is not valid according to :rfc:`5545`.
633
634 .. warning:: As time progresses, we will be stricter with the validation.
635 """
636 calendar: Self = super().new(
637 last_modified=last_modified,
638 links=links,
639 related_to=related_to,
640 refids=refids,
641 concepts=concepts,
642 subcomponents=subcomponents,
643 )
644
645 # Generate prodid if not provided but organization is given
646 if prodid is None and organization:
647 app_name = name or "Calendar"
648 lang = language.upper() if language else "EN"
649 prodid = f"-//{organization}//{app_name}//{lang}"
650 elif prodid is None:
651 prodid = DEFAULT_PRODID
652
653 calendar.prodid = prodid
654 calendar.version = version
655 calendar.calendar_name = name
656 calendar.color = color
657 calendar.description = description
658 calendar.method = method
659 calendar.calscale = calscale
660 calendar.categories = categories
661 calendar.uid = uid if uid is not None else uuid.uuid4()
662 calendar.url = url
663 calendar.refresh_interval = refresh_interval
664 calendar.source = source
665
666 return calendar
667
668 def validate(self):
669 """Validate that the calendar has required properties and components.
670
671 This method can be called explicitly to validate a calendar before output.
672
673 Raises:
674 ~error.IncompleteComponent: If the calendar lacks required properties or
675 components.
676 """
677 if not self.get("PRODID"):
678 raise IncompleteComponent("Calendar must have a PRODID")
679 if not self.get("VERSION"):
680 raise IncompleteComponent("Calendar must have a VERSION")
681 if not self.subcomponents:
682 raise IncompleteComponent(
683 "Calendar must contain at least one component (event, todo, etc.)"
684 )
685
686
687__all__ = ["Calendar"]