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