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