""":rfc:`5545` iCalendar component."""
from __future__ import annotations
import uuid
from datetime import timedelta
from typing import TYPE_CHECKING
from icalendar.attr import (
CONCEPTS_TYPE_SETTER,
LINKS_TYPE_SETTER,
RELATED_TO_TYPE_SETTER,
categories_property,
images_property,
multi_language_text_property,
single_string_property,
source_property,
uid_property,
url_property,
)
from icalendar.cal.component import Component
from icalendar.cal.examples import get_example
from icalendar.cal.timezone import Timezone
from icalendar.error import IncompleteComponent
from icalendar.parser.ical.calendar import CalendarIcalParser
from icalendar.version import __version__
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from datetime import date, datetime
from icalendar.cal.availability import Availability
from icalendar.cal.event import Event
from icalendar.cal.free_busy import FreeBusy
from icalendar.cal.journal import Journal
from icalendar.cal.todo import Todo
from icalendar.parser.ical.component import ComponentIcalParser
DEFAULT_PRODID = f"-//collective//icalendar//{__version__}//EN"
[docs]
class Calendar(Component):
"""
The "VCALENDAR" object is a collection of calendar information.
This information can include a variety of components, such as
"VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY", "VTIMEZONE", or any
other type of calendar component.
Examples:
Create a new Calendar:
>>> from icalendar import Calendar
>>> calendar = Calendar.new(name="My Calendar")
>>> print(calendar.calendar_name)
My Calendar
"""
name = "VCALENDAR"
canonical_order = (
"VERSION",
"PRODID",
"CALSCALE",
"METHOD",
"DESCRIPTION",
"X-WR-CALDESC",
"NAME",
"X-WR-CALNAME",
)
required = (
"PRODID",
"VERSION",
)
singletons = (
"PRODID",
"VERSION",
"CALSCALE",
"METHOD",
"COLOR", # RFC 7986
)
multiple = (
"CATEGORIES", # RFC 7986
"DESCRIPTION", # RFC 7986
"NAME", # RFC 7986
)
[docs]
@classmethod
def example(cls, name: str = "example") -> Calendar:
"""Return the calendar example with the given name."""
return cls.from_ical(get_example("calendars", name))
@classmethod
def _get_ical_parser(cls, st: str | bytes) -> ComponentIcalParser:
"""Get the iCal parser for the given input string."""
return CalendarIcalParser(st, cls._get_component_factory(), cls.types_factory)
@property
def events(self) -> list[Event]:
"""All event components in the calendar.
This is a shortcut to get all events.
Modifications do not change the calendar.
Use :py:meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
>>> from icalendar import Calendar
>>> calendar = Calendar.example()
>>> event = calendar.events[0]
>>> event.start
datetime.date(2022, 1, 1)
>>> print(event["SUMMARY"])
New Year's Day
"""
return self.walk("VEVENT")
@property
def todos(self) -> list[Todo]:
"""All todo components in the calendar.
This is a shortcut to get all todos.
Modifications do not change the calendar.
Use :py:meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
"""
return self.walk("VTODO")
@property
def journals(self) -> list[Journal]:
"""All journal components in the calendar.
This is a shortcut to get all journals.
Modifications do not change the calendar.
Use :py:meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
"""
return self.walk("VJOURNAL")
@property
def availabilities(self) -> list[Availability]:
"""All :class:`Availability` components in the calendar.
This is a shortcut to get all availabilities.
Modifications do not change the calendar.
Use :py:meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
"""
return self.walk("VAVAILABILITY")
@property
def freebusy(self) -> list[FreeBusy]:
"""All FreeBusy components in the calendar.
This is a shortcut to get all FreeBusy.
Modifications do not change the calendar.
Use :py:meth:`Component.add_component <icalendar.cal.component.Component.add_component>`.
"""
return self.walk("VFREEBUSY")
[docs]
def get_used_tzids(self) -> set[str]:
"""The set of TZIDs in use.
This goes through the whole calendar to find all occurrences of
timezone information like the TZID parameter in all attributes.
>>> from icalendar import Calendar
>>> calendar = Calendar.example("timezone_rdate")
>>> calendar.get_used_tzids()
{'posix/Europe/Vaduz'}
Even if you use UTC, this will not show up.
"""
result = set()
for _name, value in self.property_items(sorted=False):
if hasattr(value, "params"):
result.add(value.params.get("TZID"))
return result - {None}
[docs]
def get_missing_tzids(self) -> set[str]:
"""The set of missing timezone component tzids.
To create a :rfc:`5545` compatible calendar,
all of these timezones should be added.
UTC is excluded: per :rfc:`5545#section-3.2.19`, UTC datetimes use
the ``Z`` suffix and never require a VTIMEZONE component.
"""
tzids = self.get_used_tzids() - {"UTC"}
for timezone in self.timezones:
# discard (not remove) — a VTIMEZONE may exist for a timezone not
# referenced by any event TZID (e.g. added by x-wr-timezone conversion)
tzids.discard(timezone.tz_name)
return tzids
@property
def timezones(self) -> list[Timezone]:
"""Return the timezones components in this calendar.
>>> from icalendar import Calendar
>>> calendar = Calendar.example("pacific_fiji")
>>> [timezone.tz_name for timezone in calendar.timezones]
['custom_Pacific/Fiji']
.. note::
This is a read-only property.
"""
return self.walk("VTIMEZONE")
[docs]
def add_missing_timezones(
self,
first_date: date = Timezone.DEFAULT_FIRST_DATE,
last_date: date = Timezone.DEFAULT_LAST_DATE,
):
"""Add all missing VTIMEZONE components.
This adds all the timezone components that are required.
VTIMEZONE components are inserted at the beginning of the calendar
to ensure they appear before other components that reference them.
.. note::
Timezones that are not known will not be added.
Parameters:
first_date: Earlier than anything that happens in the calendar.
last_date: Later than anything happening in the calendar.
>>> from icalendar import Calendar, Event
>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> calendar = Calendar()
>>> event = Event()
>>> calendar.add_component(event)
>>> event.start = datetime(1990, 10, 11, 12, tzinfo=ZoneInfo("Europe/Berlin"))
>>> calendar.timezones
[]
>>> calendar.add_missing_timezones()
>>> calendar.timezones[0].tz_name
'Europe/Berlin'
>>> calendar.get_missing_tzids() # check that all are added
set()
"""
missing_tzids = self.get_missing_tzids()
if not missing_tzids:
return
existing_timezone_count = len(self.timezones)
for tzid in missing_tzids:
try:
timezone = Timezone.from_tzid(
tzid, first_date=first_date, last_date=last_date
)
except ValueError:
continue
self.subcomponents.insert(existing_timezone_count, timezone)
existing_timezone_count += 1
calendar_name = multi_language_text_property(
"NAME",
"X-WR-CALNAME",
"""The display name of this calendar, per :rfc:`7986#section-5.3`.
Implements both the ``NAME`` property (from :rfc:`7986`) and the widely-used
``X-WR-CALNAME`` extension for broader client compatibility.
Multiple language variants can be stored by setting this property more than
once, each with a different ``LANGUAGE`` parameter value.
Example:
Set the name of the calendar.
.. code-block:: pycon
>>> from icalendar import Calendar
>>> calendar = Calendar()
>>> calendar.calendar_name = "My Calendar"
>>> print(calendar.to_ical())
BEGIN:VCALENDAR
NAME:My Calendar
X-WR-CALNAME:My Calendar
END:VCALENDAR
See also:
:attr:`description`
""",
)
description = multi_language_text_property(
"DESCRIPTION",
"X-WR-CALDESC",
"""A human-readable description of this calendar's content, per :rfc:`7986#section-5.2`.
Implements both ``DESCRIPTION`` (from :rfc:`7986`) and ``X-WR-CALDESC``
for broader calendar client compatibility.
Multiple language variants can be stored by setting this property more than
once with different ``LANGUAGE`` parameter values.
Example:
Add a description to a calendar.
.. code-block:: pycon
>>> from icalendar import Calendar
>>> calendar = Calendar()
>>> calendar.description = "This is a calendar"
>>> print(calendar.to_ical())
BEGIN:VCALENDAR
DESCRIPTION:This is a calendar
X-WR-CALDESC:This is a calendar
END:VCALENDAR
See also:
:attr:`calendar_name`
""",
)
color = single_string_property(
"COLOR",
"""A CSS3 color name or value used to visually distinguish this calendar, per :rfc:`7986#section-5.9`.
Implements both ``COLOR`` (from :rfc:`7986`) and ``X-APPLE-CALENDAR-COLOR``.
The value is a case-insensitive CSS3 color name (e.g. ``"turquoise"``) or
a hex code (e.g. ``"#ffffff"``), drawn from the
`CSS3 color specification <https://www.w3.org/TR/css-color-3/>`_.
Since :rfc:`7986`, individual ``VEVENT``, ``VTODO``, and ``VJOURNAL``
subcomponents may also carry their own color.
Example:
.. code-block:: pycon
>>> from icalendar import Calendar
>>> calendar = Calendar()
>>> calendar.color = "black"
>>> print(calendar.to_ical())
BEGIN:VCALENDAR
COLOR:black
END:VCALENDAR
See also:
:attr:`calendar_name`
""",
"X-APPLE-CALENDAR-COLOR",
)
categories = categories_property
uid = uid_property
prodid = single_string_property(
"PRODID",
"""The product identifier for the software that created this iCalendar object.
Defined in :rfc:`5545#section-3.7.3` and required exactly once per calendar object.
The value should be a globally unique string. The conventional format is a
Formal Public Identifier (FPI), e.g. ``-//My Company//My Product//EN``, but any
unique string is acceptable.
Example:
Set a custom product identifier on a new calendar.
.. code-block:: pycon
>>> from icalendar import Calendar
>>> cal = Calendar()
>>> cal.prodid = "-//MyApp//MyCalendar//EN"
>>> str(cal.prodid)
'-//MyApp//MyCalendar//EN'
See also:
:attr:`version`
""",
)
version = single_string_property(
"VERSION",
"""The iCalendar specification version required to interpret this object.
Defined in :rfc:`5545#section-3.7.4` and required exactly once per calendar object.
The value ``"2.0"`` indicates :rfc:`5545` compliance, which is the default used
by this library. A range such as ``"1.0;2.0"`` may indicate minimum and maximum
supported versions.
Example:
.. code-block:: pycon
>>> from icalendar import Calendar
>>> cal = Calendar()
>>> cal.version = "2.0"
>>> str(cal.version)
'2.0'
See also:
:attr:`prodid`, :attr:`calscale`
""",
)
calscale = single_string_property(
"CALSCALE",
"""The calendar scale for date and time values in this iCalendar object.
Defined in :rfc:`5545#section-3.7.1`. The only value currently defined is
``"GREGORIAN"`` (the default). When this property is absent, Gregorian is assumed.
Per :rfc:`7529`, non-Gregorian calendar systems are expressed via ``RRULE``
transformations rather than a different ``CALSCALE`` value.
Example:
.. code-block:: pycon
>>> from icalendar import Calendar
>>> cal = Calendar()
>>> cal.calscale
'GREGORIAN'
See also:
:attr:`version`
""",
default="GREGORIAN",
)
method = single_string_property(
"METHOD",
"""The iTIP scheduling method associated with this calendar object, per :rfc:`5545#section-3.7.2`.
When present, ``METHOD`` indicates that this object is part of a scheduling
transaction (e.g., a meeting invitation or cancellation). Scheduling methods
are defined by :rfc:`5546` (iTIP), with values such as ``"REQUEST"``,
``"REPLY"``, ``"CANCEL"``, and ``"PUBLISH"``.
When used inside a MIME message, this value must match the ``method`` parameter
of the ``Content-Type`` header. If absent, the calendar is treated as a plain
data snapshot with no scheduling semantics.
Example:
.. code-block:: pycon
>>> from icalendar import Calendar
>>> cal = Calendar()
>>> cal.method = "REQUEST"
>>> str(cal.method)
'REQUEST'
See also:
:attr:`version`, :rfc:`5546`
""",
)
url = url_property
source = source_property
@property
def refresh_interval(self) -> timedelta | None:
"""A suggested minimum polling interval for fetching updates to this calendar, per :rfc:`7986#section-5.7`.
Returns a :class:`~datetime.timedelta` or ``None`` when not set.
Calendar clients should not poll more frequently than this interval.
The value must be a positive duration.
Raises:
ValueError: When setting a non-positive (zero or negative) duration.
TypeError: When setting a value that is not a :class:`~datetime.timedelta` or ``None``.
Example:
.. code-block:: pycon
>>> from datetime import timedelta
>>> from icalendar import Calendar
>>> cal = Calendar()
>>> cal.refresh_interval = timedelta(hours=1)
>>> cal.refresh_interval
datetime.timedelta(seconds=3600)
See also:
:attr:`source`
"""
refresh_interval = self.get("REFRESH-INTERVAL")
return refresh_interval.dt if refresh_interval else None
@refresh_interval.setter
def refresh_interval(self, value: timedelta | None):
"""Set the REFRESH-INTERVAL."""
if not isinstance(value, timedelta) and value is not None:
raise TypeError(
"REFRESH-INTERVAL must be either a positive timedelta,"
" or None to delete it."
)
if value is not None and value.total_seconds() <= 0:
raise ValueError("REFRESH-INTERVAL must be a positive timedelta.")
if value is not None:
del self.refresh_interval
self.add("REFRESH-INTERVAL", value)
else:
del self.refresh_interval
@refresh_interval.deleter
def refresh_interval(self):
"""Delete REFRESH-INTERVAL."""
self.pop("REFRESH-INTERVAL")
images = images_property
[docs]
@classmethod
def new(
cls,
/,
calscale: str | None = None,
categories: Sequence[str] = (),
color: str | None = None,
concepts: CONCEPTS_TYPE_SETTER = None,
description: str | None = None,
language: str | None = None,
last_modified: date | datetime | None = None,
links: LINKS_TYPE_SETTER = None,
method: str | None = None,
name: str | None = None,
organization: str | None = None,
prodid: str | None = None,
refresh_interval: timedelta | None = None,
refids: list[str] | str | None = None,
related_to: RELATED_TO_TYPE_SETTER = None,
source: str | None = None,
subcomponents: Iterable[Component] | None = None,
uid: str | uuid.UUID | None = None,
url: str | None = None,
version: str = "2.0",
):
"""Create a new Calendar with all required properties.
This creates a new Calendar in accordance with :rfc:`5545` and :rfc:`7986`.
Parameters:
calscale: The :attr:`calscale` of the calendar.
categories: The :attr:`categories` of the calendar.
color: The :attr:`color` of the calendar.
concepts: The :attr:`~icalendar.Component.concepts` of the calendar.
description: The :attr:`description` of the calendar.
language: The language for the calendar. Used to generate localized `prodid`.
last_modified: The :attr:`~icalendar.Component.last_modified` of the calendar.
links: The :attr:`~icalendar.Component.links` of the calendar.
method: The :attr:`method` of the calendar.
name: The :attr:`calendar_name` of the calendar.
organization: The organization name. Used to generate `prodid` if not provided.
prodid: The :attr:`prodid` of the component. If None and organization is provided,
generates a `prodid` in format "-//organization//name//language".
refresh_interval: The :attr:`refresh_interval` of the calendar.
refids: :attr:`~icalendar.Component.refids` of the calendar.
related_to: :attr:`~icalendar.Component.related_to` of the calendar.
source: The :attr:`source` of the calendar.
subcomponents: The subcomponents of the calendar.
uid: The :attr:`uid` of the calendar.
If None, this is set to a new :func:`uuid.uuid4`.
url: The :attr:`url` of the calendar.
version: The :attr:`version` of the calendar.
Returns:
:class:`Calendar`
Raises:
~error.InvalidCalendar: If the content is not valid according to :rfc:`5545`.
.. warning:: As time progresses, we will be stricter with the validation.
"""
calendar: Calendar = super().new(
last_modified=last_modified,
links=links,
related_to=related_to,
refids=refids,
concepts=concepts,
subcomponents=subcomponents,
)
# Generate prodid if not provided but organization is given
if prodid is None and organization:
app_name = name or "Calendar"
lang = language.upper() if language else "EN"
prodid = f"-//{organization}//{app_name}//{lang}"
elif prodid is None:
prodid = DEFAULT_PRODID
calendar.prodid = prodid
calendar.version = version
calendar.calendar_name = name
calendar.color = color
calendar.description = description
calendar.method = method
calendar.calscale = calscale
calendar.categories = categories
calendar.uid = uid if uid is not None else uuid.uuid4()
calendar.url = url
calendar.refresh_interval = refresh_interval
calendar.source = source
return calendar
[docs]
def validate(self):
"""Validate that the calendar has required properties and components.
This method can be called explicitly to validate a calendar before output.
Raises:
~error.IncompleteComponent: If the calendar lacks required properties or
components.
"""
if not self.get("PRODID"):
raise IncompleteComponent("Calendar must have a PRODID")
if not self.get("VERSION"):
raise IncompleteComponent("Calendar must have a VERSION")
if not self.subcomponents:
raise IncompleteComponent(
"Calendar must contain at least one component (event, todo, etc.)"
)
__all__ = ["Calendar"]