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