1""":rfc:`5545` iCalendar component."""
2
3from __future__ import annotations
4
5import uuid
6from datetime import timedelta
7from typing import TYPE_CHECKING, Literal, 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.version import __version__
26
27if TYPE_CHECKING:
28 from collections.abc import Iterable, Sequence
29 from datetime import date, datetime
30
31 from icalendar.cal.availability import Availability
32 from icalendar.cal.event import Event
33 from icalendar.cal.free_busy import FreeBusy
34 from icalendar.cal.todo import Todo
35
36
37class Calendar(Component):
38 """
39 The "VCALENDAR" object is a collection of calendar information.
40 This information can include a variety of components, such as
41 "VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY", "VTIMEZONE", or any
42 other type of calendar component.
43
44 Examples:
45 Create a new Calendar:
46
47 >>> from icalendar import Calendar
48 >>> calendar = Calendar.new(name="My Calendar")
49 >>> print(calendar.calendar_name)
50 My Calendar
51
52 """
53
54 name = "VCALENDAR"
55 canonical_order = (
56 "VERSION",
57 "PRODID",
58 "CALSCALE",
59 "METHOD",
60 "DESCRIPTION",
61 "X-WR-CALDESC",
62 "NAME",
63 "X-WR-CALNAME",
64 )
65 required = (
66 "PRODID",
67 "VERSION",
68 )
69 singletons = (
70 "PRODID",
71 "VERSION",
72 "CALSCALE",
73 "METHOD",
74 "COLOR", # RFC 7986
75 )
76 multiple = (
77 "CATEGORIES", # RFC 7986
78 "DESCRIPTION", # RFC 7986
79 "NAME", # RFC 7986
80 )
81
82 @classmethod
83 def example(cls, name: str = "example") -> Calendar:
84 """Return the calendar example with the given name."""
85 return cls.from_ical(get_example("calendars", name))
86
87 @overload
88 @classmethod
89 def from_ical(
90 cls, st: bytes | str, multiple: Literal[False] = False
91 ) -> Calendar: ...
92
93 @overload
94 @classmethod
95 def from_ical(cls, st: bytes | str, multiple: Literal[True]) -> list[Calendar]: ...
96
97 @classmethod
98 def from_ical(
99 cls,
100 st: bytes | str,
101 multiple: bool = False, # noqa: FBT001
102 ) -> Calendar | list[Calendar]:
103 """Parse iCalendar data into Calendar instances.
104
105 Wraps :meth:`Component.from_ical() <icalendar.cal.component.Component.from_ical>` with
106 timezone forward-reference resolution and VTIMEZONE caching.
107
108 Parameters:
109 st: iCalendar data as bytes or string
110 multiple: If ``True``, returns list. If ``False``, returns single calendar.
111
112 Returns:
113 Calendar or list of Calendars
114 """
115 comps = Component.from_ical(st, multiple=True)
116 all_timezones_so_far = True
117 for comp in comps:
118 for component in comp.subcomponents:
119 if component.name == "VTIMEZONE":
120 if not all_timezones_so_far:
121 # If a preceding component refers to a VTIMEZONE defined
122 # later in the source st
123 # (forward references are allowed by RFC 5545), then the
124 # earlier component may have
125 # the wrong timezone attached.
126 # However, during computation of comps, all VTIMEZONEs
127 # observed do end up in
128 # the timezone cache. So simply re-running from_ical will
129 # rely on the cache
130 # for those forward references to produce the correct result.
131 # See test_create_america_new_york_forward_reference.
132 return Component.from_ical(st, multiple)
133 else:
134 all_timezones_so_far = False
135
136 # No potentially forward VTIMEZONEs to worry about
137 if multiple:
138 return comps
139 if len(comps) > 1:
140 raise ValueError(
141 cls._format_error(
142 "Found multiple components where only one is allowed", st
143 )
144 )
145 if len(comps) < 1:
146 raise ValueError(
147 cls._format_error(
148 "Found no components where exactly one is required", st
149 )
150 )
151 return comps[0]
152
153 @property
154 def events(self) -> list[Event]:
155 """All event components in the calendar.
156
157 This is a shortcut to get all events.
158 Modifications do not change the calendar.
159 Use :py:meth:`Component.add_component`.
160
161 >>> from icalendar import Calendar
162 >>> calendar = Calendar.example()
163 >>> event = calendar.events[0]
164 >>> event.start
165 datetime.date(2022, 1, 1)
166 >>> print(event["SUMMARY"])
167 New Year's Day
168 """
169 return self.walk("VEVENT")
170
171 @property
172 def todos(self) -> list[Todo]:
173 """All todo components in the calendar.
174
175 This is a shortcut to get all todos.
176 Modifications do not change the calendar.
177 Use :py:meth:`Component.add_component`.
178 """
179 return self.walk("VTODO")
180
181 @property
182 def availabilities(self) -> list[Availability]:
183 """All :class:`Availability` components in the calendar.
184
185 This is a shortcut to get all availabilities.
186 Modifications do not change the calendar.
187 Use :py:meth:`Component.add_component`.
188 """
189 return self.walk("VAVAILABILITY")
190
191 @property
192 def freebusy(self) -> list[FreeBusy]:
193 """All FreeBusy components in the calendar.
194
195 This is a shortcut to get all FreeBusy.
196 Modifications do not change the calendar.
197 Use :py:meth:`Component.add_component`.
198 """
199 return self.walk("VFREEBUSY")
200
201 def get_used_tzids(self) -> set[str]:
202 """The set of TZIDs in use.
203
204 This goes through the whole calendar to find all occurrences of
205 timezone information like the TZID parameter in all attributes.
206
207 >>> from icalendar import Calendar
208 >>> calendar = Calendar.example("timezone_rdate")
209 >>> calendar.get_used_tzids()
210 {'posix/Europe/Vaduz'}
211
212 Even if you use UTC, this will not show up.
213 """
214 result = set()
215 for _name, value in self.property_items(sorted=False):
216 if hasattr(value, "params"):
217 result.add(value.params.get("TZID"))
218 return result - {None}
219
220 def get_missing_tzids(self) -> set[str]:
221 """The set of missing timezone component tzids.
222
223 To create a :rfc:`5545` compatible calendar,
224 all of these timezones should be added.
225 """
226 tzids = self.get_used_tzids()
227 for timezone in self.timezones:
228 tzids.remove(timezone.tz_name)
229 return tzids
230
231 @property
232 def timezones(self) -> list[Timezone]:
233 """Return the timezones components in this calendar.
234
235 >>> from icalendar import Calendar
236 >>> calendar = Calendar.example("pacific_fiji")
237 >>> [timezone.tz_name for timezone in calendar.timezones]
238 ['custom_Pacific/Fiji']
239
240 .. note::
241
242 This is a read-only property.
243 """
244 return self.walk("VTIMEZONE")
245
246 def add_missing_timezones(
247 self,
248 first_date: date = Timezone.DEFAULT_FIRST_DATE,
249 last_date: date = Timezone.DEFAULT_LAST_DATE,
250 ):
251 """Add all missing VTIMEZONE components.
252
253 This adds all the timezone components that are required.
254 VTIMEZONE components are inserted at the beginning of the calendar
255 to ensure they appear before other components that reference them.
256
257 .. note::
258
259 Timezones that are not known will not be added.
260
261 :param first_date: earlier than anything that happens in the calendar
262 :param last_date: later than anything happening in the calendar
263
264 >>> from icalendar import Calendar, Event
265 >>> from datetime import datetime
266 >>> from zoneinfo import ZoneInfo
267 >>> calendar = Calendar()
268 >>> event = Event()
269 >>> calendar.add_component(event)
270 >>> event.start = datetime(1990, 10, 11, 12, tzinfo=ZoneInfo("Europe/Berlin"))
271 >>> calendar.timezones
272 []
273 >>> calendar.add_missing_timezones()
274 >>> calendar.timezones[0].tz_name
275 'Europe/Berlin'
276 >>> calendar.get_missing_tzids() # check that all are added
277 set()
278 """
279 missing_tzids = self.get_missing_tzids()
280 if not missing_tzids:
281 return
282
283 existing_timezone_count = len(self.timezones)
284
285 for tzid in missing_tzids:
286 try:
287 timezone = Timezone.from_tzid(
288 tzid, first_date=first_date, last_date=last_date
289 )
290 except ValueError:
291 continue
292 self.subcomponents.insert(existing_timezone_count, timezone)
293 existing_timezone_count += 1
294
295 calendar_name = multi_language_text_property(
296 "NAME",
297 "X-WR-CALNAME",
298 """This property specifies the name of the calendar.
299
300 This implements :rfc:`7986` ``NAME`` and ``X-WR-CALNAME``.
301
302 Property Parameters:
303 IANA, non-standard, alternate text
304 representation, and language property parameters can be specified
305 on this property.
306
307 Conformance:
308 This property can be specified multiple times in an
309 iCalendar object. However, each property MUST represent the name
310 of the calendar in a different language.
311
312 Description:
313 This property is used to specify a name of the
314 iCalendar object that can be used by calendar user agents when
315 presenting the calendar data to a user. Whilst a calendar only
316 has a single name, multiple language variants can be specified by
317 including this property multiple times with different "LANGUAGE"
318 parameter values on each.
319
320 Example:
321 Below, we set the name of the calendar.
322
323 .. code-block:: pycon
324
325 >>> from icalendar import Calendar
326 >>> calendar = Calendar()
327 >>> calendar.calendar_name = "My Calendar"
328 >>> print(calendar.to_ical())
329 BEGIN:VCALENDAR
330 NAME:My Calendar
331 END:VCALENDAR
332 """,
333 )
334
335 description = multi_language_text_property(
336 "DESCRIPTION",
337 "X-WR-CALDESC",
338 """This property specifies the description of the calendar.
339
340 This implements :rfc:`7986` ``DESCRIPTION`` and ``X-WR-CALDESC``.
341
342 Conformance:
343 This property can be specified multiple times in an
344 iCalendar object. However, each property MUST represent the
345 description of the calendar in a different language.
346
347 Description:
348 This property is used to specify a lengthy textual
349 description of the iCalendar object that can be used by calendar
350 user agents when describing the nature of the calendar data to a
351 user. Whilst a calendar only has a single description, multiple
352 language variants can be specified by including this property
353 multiple times with different "LANGUAGE" parameter values on each.
354
355 Example:
356 Below, we add a description to a calendar.
357
358 .. code-block:: pycon
359
360 >>> from icalendar import Calendar
361 >>> calendar = Calendar()
362 >>> calendar.description = "This is a calendar"
363 >>> print(calendar.to_ical())
364 BEGIN:VCALENDAR
365 DESCRIPTION:This is a calendar
366 END:VCALENDAR
367 """,
368 )
369
370 color = single_string_property(
371 "COLOR",
372 """This property specifies a color used for displaying the calendar.
373
374 This implements :rfc:`7986` ``COLOR`` and ``X-APPLE-CALENDAR-COLOR``.
375 Please note that since :rfc:`7986`, subcomponents can have their own color.
376
377 Property Parameters:
378 IANA and non-standard property parameters can
379 be specified on this property.
380
381 Conformance:
382 This property can be specified once in an iCalendar
383 object or in ``VEVENT``, ``VTODO``, or ``VJOURNAL`` calendar components.
384
385 Description:
386 This property specifies a color that clients MAY use
387 when presenting the relevant data to a user. Typically, this
388 would appear as the "background" color of events or tasks. The
389 value is a case-insensitive color name taken from the CSS3 set of
390 names, defined in Section 4.3 of `W3C.REC-css3-color-20110607 <https://www.w3.org/TR/css-color-3/>`_.
391
392 Example:
393 ``"turquoise"``, ``"#ffffff"``
394
395 .. code-block:: pycon
396
397 >>> from icalendar import Calendar
398 >>> calendar = Calendar()
399 >>> calendar.color = "black"
400 >>> print(calendar.to_ical())
401 BEGIN:VCALENDAR
402 COLOR:black
403 END:VCALENDAR
404
405 """,
406 "X-APPLE-CALENDAR-COLOR",
407 )
408 categories = categories_property
409 uid = uid_property
410 prodid = single_string_property(
411 "PRODID",
412 """PRODID specifies the identifier for the product that created the iCalendar object.
413
414Conformance:
415 The property MUST be specified once in an iCalendar object.
416
417Description:
418 The vendor of the implementation SHOULD assure that
419 this is a globally unique identifier; using some technique such as
420 an FPI value, as defined in [ISO.9070.1991].
421
422 This property SHOULD NOT be used to alter the interpretation of an
423 iCalendar object beyond the semantics specified in this memo. For
424 example, it is not to be used to further the understanding of non-
425 standard properties.
426
427Example:
428 The following is an example of this property. It does not
429 imply that English is the default language.
430
431 .. code-block:: text
432
433 -//ABC Corporation//NONSGML My Product//EN
434""", # noqa: E501
435 )
436 version = single_string_property(
437 "VERSION",
438 """VERSION of the calendar specification.
439
440The default is ``"2.0"`` for :rfc:`5545`.
441
442Purpose:
443 This property specifies the identifier corresponding to the
444 highest version number or the minimum and maximum range of the
445 iCalendar specification that is required in order to interpret the
446 iCalendar object.
447
448
449 """,
450 )
451
452 calscale = single_string_property(
453 "CALSCALE",
454 """CALSCALE defines the calendar scale used for the calendar information specified in the iCalendar object.
455
456Compatibility:
457 :rfc:`7529` makes the case that GREGORIAN stays the default and other calendar scales
458 are implemented on the RRULE.
459
460Conformance:
461 This property can be specified once in an iCalendar
462 object. The default value is "GREGORIAN".
463
464Description:
465 This memo is based on the Gregorian calendar scale.
466 The Gregorian calendar scale is assumed if this property is not
467 specified in the iCalendar object. It is expected that other
468 calendar scales will be defined in other specifications or by
469 future versions of this memo.
470 """, # noqa: E501
471 default="GREGORIAN",
472 )
473 method = single_string_property(
474 "METHOD",
475 """METHOD defines the iCalendar object method associated with the calendar object.
476
477Description:
478 When used in a MIME message entity, the value of this
479 property MUST be the same as the Content-Type "method" parameter
480 value. If either the "METHOD" property or the Content-Type
481 "method" parameter is specified, then the other MUST also be
482 specified.
483
484 No methods are defined by this specification. This is the subject
485 of other specifications, such as the iCalendar Transport-
486 independent Interoperability Protocol (iTIP) defined by :rfc:`5546`.
487
488 If this property is not present in the iCalendar object, then a
489 scheduling transaction MUST NOT be assumed. In such cases, the
490 iCalendar object is merely being used to transport a snapshot of
491 some calendar information; without the intention of conveying a
492 scheduling semantic.
493""", # noqa: E501
494 )
495 url = url_property
496 source = source_property
497
498 @property
499 def refresh_interval(self) -> timedelta | None:
500 """REFRESH-INTERVAL specifies a suggested minimum interval for
501 polling for changes of the calendar data from the original source
502 of that data.
503
504 Conformance:
505 This property can be specified once in an iCalendar
506 object, consisting of a positive duration of time.
507
508 Description:
509 This property specifies a positive duration that gives
510 a suggested minimum polling interval for checking for updates to
511 the calendar data. The value of this property SHOULD be used by
512 calendar user agents to limit the polling interval for calendar
513 data updates to the minimum interval specified.
514
515 Raises:
516 ValueError: When setting a negative duration.
517 """
518 refresh_interval = self.get("REFRESH-INTERVAL")
519 return refresh_interval.dt if refresh_interval else None
520
521 @refresh_interval.setter
522 def refresh_interval(self, value: timedelta | None):
523 """Set the REFRESH-INTERVAL."""
524 if not isinstance(value, timedelta) and value is not None:
525 raise TypeError(
526 "REFRESH-INTERVAL must be either a positive timedelta,"
527 " or None to delete it."
528 )
529 if value is not None and value.total_seconds() <= 0:
530 raise ValueError("REFRESH-INTERVAL must be a positive timedelta.")
531 if value is not None:
532 del self.refresh_interval
533 self.add("REFRESH-INTERVAL", value)
534 else:
535 del self.refresh_interval
536
537 @refresh_interval.deleter
538 def refresh_interval(self):
539 """Delete REFRESH-INTERVAL."""
540 self.pop("REFRESH-INTERVAL")
541
542 images = images_property
543
544 @classmethod
545 def new(
546 cls,
547 /,
548 calscale: str | None = None,
549 categories: Sequence[str] = (),
550 color: str | None = None,
551 concepts: CONCEPTS_TYPE_SETTER = None,
552 description: str | None = None,
553 language: str | None = None,
554 last_modified: date | datetime | None = None,
555 links: LINKS_TYPE_SETTER = None,
556 method: str | None = None,
557 name: str | None = None,
558 organization: str | None = None,
559 prodid: str | None = None,
560 refresh_interval: timedelta | None = None,
561 refids: list[str] | str | None = None,
562 related_to: RELATED_TO_TYPE_SETTER = None,
563 source: str | None = None,
564 subcomponents: Iterable[Component] | None = None,
565 uid: str | uuid.UUID | None = None,
566 url: str | None = None,
567 version: str = "2.0",
568 ):
569 """Create a new Calendar with all required properties.
570
571 This creates a new Calendar in accordance with :rfc:`5545` and :rfc:`7986`.
572
573 Parameters:
574 calscale: The :attr:`calscale` of the calendar.
575 categories: The :attr:`categories` of the calendar.
576 color: The :attr:`color` of the calendar.
577 concepts: The :attr:`~icalendar.Component.concepts` of the calendar.
578 description: The :attr:`description` of the calendar.
579 language: The language for the calendar. Used to generate localized `prodid`.
580 last_modified: The :attr:`~icalendar.Component.last_modified` of the calendar.
581 links: The :attr:`~icalendar.Component.links` of the calendar.
582 method: The :attr:`method` of the calendar.
583 name: The :attr:`calendar_name` of the calendar.
584 organization: The organization name. Used to generate `prodid` if not provided.
585 prodid: The :attr:`prodid` of the component. If None and organization is provided,
586 generates a `prodid` in format "-//organization//name//language".
587 refresh_interval: The :attr:`refresh_interval` of the calendar.
588 refids: :attr:`~icalendar.Component.refids` of the calendar.
589 related_to: :attr:`~icalendar.Component.related_to` of the calendar.
590 source: The :attr:`source` of the calendar.
591 subcomponents: The subcomponents of the calendar.
592 uid: The :attr:`uid` of the calendar.
593 If None, this is set to a new :func:`uuid.uuid4`.
594 url: The :attr:`url` of the calendar.
595 version: The :attr:`version` of the calendar.
596
597 Returns:
598 :class:`Calendar`
599
600 Raises:
601 ~error.InvalidCalendar: If the content is not valid according to :rfc:`5545`.
602
603 .. warning:: As time progresses, we will be stricter with the validation.
604 """ # noqa: E501
605 calendar: Calendar = super().new(
606 last_modified=last_modified,
607 links=links,
608 related_to=related_to,
609 refids=refids,
610 concepts=concepts,
611 )
612
613 # Generate prodid if not provided but organization is given
614 if prodid is None and organization:
615 app_name = name or "Calendar"
616 lang = language.upper() if language else "EN"
617 prodid = f"-//{organization}//{app_name}//{lang}"
618 elif prodid is None:
619 prodid = f"-//collective//icalendar//{__version__}//EN"
620
621 calendar.prodid = prodid
622 calendar.version = version
623 calendar.calendar_name = name
624 calendar.color = color
625 calendar.description = description
626 calendar.method = method
627 calendar.calscale = calscale
628 calendar.categories = categories
629 calendar.uid = uid if uid is not None else uuid.uuid4()
630 calendar.url = url
631 calendar.refresh_interval = refresh_interval
632 calendar.source = source
633 if subcomponents is not None:
634 calendar.subcomponents = list(subcomponents)
635
636 return calendar
637
638 def validate(self):
639 """Validate that the calendar has required properties and components.
640
641 This method can be called explicitly to validate a calendar before output.
642
643 Raises:
644 ~error.IncompleteComponent: If the calendar lacks required properties or
645 components.
646 """
647 if not self.get("PRODID"):
648 raise IncompleteComponent("Calendar must have a PRODID")
649 if not self.get("VERSION"):
650 raise IncompleteComponent("Calendar must have a VERSION")
651 if not self.subcomponents:
652 raise IncompleteComponent(
653 "Calendar must contain at least one component (event, todo, etc.)"
654 )
655
656
657__all__ = ["Calendar"]