"""RECUR property type from :rfc:`5545`."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from xml.etree.ElementTree import Element, SubElement
from icalendar.caselessdict import CaselessDict
from icalendar.compatibility import Self
from icalendar.error import InvalidCalendar, JCalParsingError, XCalParsingError
from icalendar.parser import Parameters
from icalendar.parser.xcal.wrapper import from_xcal_wrapper
from icalendar.parser_tools import DEFAULT_ENCODING, SEQUENCE_TYPES
from icalendar.prop.dt import vDDDTypes
from icalendar.prop.integer import vInt, vNonNegativeInt
from icalendar.prop.recur.frequency import vFrequency
from icalendar.prop.recur.month import vMonth
from icalendar.prop.recur.skip import vSkip
from icalendar.prop.recur.weekday import vWeekday
from icalendar.prop.text import vText
if TYPE_CHECKING:
from collections.abc import Callable
from icalendar.compatibility import Self
from icalendar.parser.xcal.base import XCalParser
from icalendar.prop import VPROPERTY
def _contains_part_separator(value: str) -> bool:
"""Whether a text value of a recurrence rule part contains a separator.
RECUR has no escaping, so a text value, such as that of ``RSCALE`` or an
unknown part, cannot contain the part separator ``;`` or the list
separator ``,``.
Parameters:
value: A value of one key/value pair in :class:`vRecur`.
Returns:
Whether a separator is present and the value must be rejected.
"""
return ";" in value or "," in value
[docs]
class vRecur(CaselessDict):
"""Recurrence definition.
The dateutil spelling ``byweekday`` is accepted as an alias for the
iCalendar ``BYDAY`` rule part. It is normalized to ``BYDAY`` when a
recurrence is parsed or serialized.
Property Name:
RRULE
Purpose:
This property defines a rule or repeating pattern for recurring events, to-dos,
journal entries, or time zone definitions.
Value Type:
RECUR
Property Parameters:
IANA and non-standard property parameters can be specified on this property.
Conformance:
This property can be specified in recurring "VEVENT", "VTODO", and "VJOURNAL"
calendar components as well as in the "STANDARD" and "DAYLIGHT" sub-components
of the "VTIMEZONE" calendar component, but it SHOULD NOT be specified more than once.
The recurrence set generated with multiple "RRULE" properties is undefined.
Description:
The recurrence rule, if specified, is used in computing the recurrence set.
The recurrence set is the complete set of recurrence instances for a calendar component.
The recurrence set is generated by considering the initial "DTSTART" property along
with the "RRULE", "RDATE", and "EXDATE" properties contained within the
recurring component. The "DTSTART" property defines the first instance in the
recurrence set. The "DTSTART" property value SHOULD be synchronized with the
recurrence rule, if specified. The recurrence set generated with a "DTSTART" property
value not synchronized with the recurrence rule is undefined.
The final recurrence set is generated by gathering all of the start DATE-TIME
values generated by any of the specified "RRULE" and "RDATE" properties, and then
excluding any start DATE-TIME values specified by "EXDATE" properties.
This implies that start DATE- TIME values specified by "EXDATE" properties take
precedence over those specified by inclusion properties (i.e., "RDATE" and "RRULE").
Where duplicate instances are generated by the "RRULE" and "RDATE" properties,
only one recurrence is considered. Duplicate instances are ignored.
The "DTSTART" property specified within the iCalendar object defines the first
instance of the recurrence. In most cases, a "DTSTART" property of DATE-TIME value
type used with a recurrence rule, should be specified as a date with local time
and time zone reference to make sure all the recurrence instances start at the
same local time regardless of time zone changes.
If the duration of the recurring component is specified with the "DTEND" or
"DUE" property, then the same exact duration will apply to all the members of the
generated recurrence set. Else, if the duration of the recurring component is
specified with the "DURATION" property, then the same nominal duration will apply
to all the members of the generated recurrence set and the exact duration of each
recurrence instance will depend on its specific start time. For example, recurrence
instances of a nominal duration of one day will have an exact duration of more or less
than 24 hours on a day where a time zone shift occurs. The duration of a specific
recurrence may be modified in an exception component or simply by using an
"RDATE" property of PERIOD value type.
Examples:
The following RRULE specifies daily events for 10 occurrences.
.. code-block:: ics
RRULE:FREQ=DAILY;COUNT=10
Below, we parse the RRULE ical string.
.. code-block:: pycon
>>> from icalendar.prop import vRecur
>>> rrule = vRecur.from_ical('FREQ=DAILY;COUNT=10')
>>> rrule
vRecur({'FREQ': ['DAILY'], 'COUNT': [10]})
You can choose to add an rrule to an :class:`icalendar.cal.Event` or
:class:`icalendar.cal.Todo`.
.. code-block:: pycon
>>> from icalendar import Event
>>> event = Event()
>>> event.add('RRULE', 'FREQ=DAILY;COUNT=10')
>>> event.rrules
[vRecur({'FREQ': ['DAILY'], 'COUNT': [10]})]
"""
default_value: ClassVar[str] = "RECUR"
params: Parameters
frequencies = [
"SECONDLY",
"MINUTELY",
"HOURLY",
"DAILY",
"WEEKLY",
"MONTHLY",
"YEARLY",
]
# Mac iCal ignores RRULEs where FREQ is not the first rule part.
# Sorts parts according to the order listed in RFC 5545, section 3.3.10.
canonical_order = (
"RSCALE",
"FREQ",
"UNTIL",
"COUNT",
"INTERVAL",
"BYSECOND",
"BYMINUTE",
"BYHOUR",
"BYDAY",
"BYMONTHDAY",
"BYYEARDAY",
"BYWEEKNO",
"BYMONTH",
"BYSETPOS",
"WKST",
"SKIP",
)
types: ClassVar[
dict[str, type[vInt | vMonth | vFrequency | vWeekday | vSkip | vText]]
] = CaselessDict(
{
"COUNT": vNonNegativeInt,
"INTERVAL": vNonNegativeInt,
"BYSECOND": vNonNegativeInt,
"BYMINUTE": vNonNegativeInt,
"BYHOUR": vNonNegativeInt,
"BYWEEKNO": vInt,
"BYMONTHDAY": vInt,
"BYYEARDAY": vInt,
"BYMONTH": vMonth,
"UNTIL": vDDDTypes,
"BYSETPOS": vInt,
"WKST": vWeekday,
"BYDAY": vWeekday,
"FREQ": vFrequency,
"SKIP": vSkip, # RFC 7529
"RSCALE": vText, # RFC 7529
}
)
# for reproducible serialization:
# RULE: if and only if it can be a list it will be a list
# look up in RFC
jcal_not_a_list = {"FREQ", "UNTIL", "COUNT", "INTERVAL", "WKST", "SKIP", "RSCALE"}
def __init__(
self, *args: Any, params: dict[str, Any] | None = None, **kwargs: Any
) -> None:
if args and isinstance(args[0], str):
# we have a string as an argument.
args = (self.from_ical(args[0]),) + args[1:]
for k, v in kwargs.items():
if not isinstance(v, SEQUENCE_TYPES):
kwargs[k] = [v]
super().__init__(*args, **kwargs)
self._normalize_byweekday()
self.params = Parameters(params)
@staticmethod
def _normalize_key(key: str) -> str:
"""Return the iCalendar name for a recurrence rule part."""
return "BYDAY" if key.upper() == "BYWEEKDAY" else key
def _normalize_byweekday(self) -> None:
"""Normalize the dateutil spelling to the RFC 5545 spelling."""
if "BYWEEKDAY" in self:
if "BYDAY" in self:
raise ValueError("BYDAY and BYWEEKDAY cannot both be specified")
self["BYDAY"] = self.pop("BYWEEKDAY")
def _normalized(self) -> Self:
"""Return a recurrence with canonical rule-part names."""
return type(self)(self) if "BYWEEKDAY" in self else self
[docs]
def to_ical(self):
result = []
for key, vals in self._normalized().sorted_items():
typ = self.types.get(key, vText)
if not isinstance(vals, SEQUENCE_TYPES):
vals = [vals]
param_vals = b",".join(typ(val).to_ical() for val in vals)
# CaselessDict keys are always unicode
param_key = key.encode(DEFAULT_ENCODING)
result.append(param_key + b"=" + param_vals)
return b";".join(result)
[docs]
@classmethod
def parse_type(cls, key, values):
# integers
key = cls._normalize_key(key)
parser = cls.types.get(key, vText)
return [parser.from_ical(v) for v in values.split(",")]
[docs]
@classmethod
def from_ical(cls, ical: str):
if isinstance(ical, cls):
return ical
try:
recur = cls()
for pairs in ical.split(";"):
try:
key, vals = pairs.split("=")
except ValueError:
# E.g. incorrect trailing semicolon, like (issue #157):
# FREQ=YEARLY;BYMONTH=11;BYDAY=1SU;
continue
key = cls._normalize_key(key)
recur[key] = cls.parse_type(key, vals)
return cls(recur)
except ValueError:
raise
except Exception as e:
raise ValueError(f"Error in recurrence rule: {ical}") from e
[docs]
@classmethod
def examples(cls) -> list[Self]:
"""Examples of vRecur."""
return [cls.from_ical("FREQ=DAILY;COUNT=10")]
from icalendar.param import VALUE
[docs]
def to_jcal(self, name: str) -> list:
"""The jCal representation of this property according to :rfc:`7265`."""
recur = {}
for k, v in self._normalized().sorted_items():
key = k.lower()
if key.upper() in self.jcal_not_a_list:
value = v[0] if isinstance(v, list) and len(v) == 1 else v
elif not isinstance(v, list):
value = [v]
else:
value = v
recur[key] = value
if "until" in recur:
until = recur["until"]
until_jcal = vDDDTypes(until).to_jcal("until")
recur["until"] = until_jcal[-1]
return [name, self.params.to_jcal(), self.VALUE.lower(), recur]
[docs]
@classmethod
def from_jcal(cls, jcal_property: list) -> Self:
"""Parse jCal from :rfc:`7265`.
Parameters:
jcal_property: The jCal property to parse.
Raises:
~icalendar.error.JCalParsingError: If the provided jCal is invalid.
"""
JCalParsingError.validate_property(jcal_property, cls)
params = Parameters.from_jcal_property(jcal_property)
if not isinstance(jcal_property[3], dict) or not all(
isinstance(k, str) for k in jcal_property[3]
):
raise JCalParsingError(
"The recurrence rule must be a mapping with string keys.",
cls,
3,
value=jcal_property[3],
)
recur = {}
for key, value in jcal_property[3].items():
JCalParsingError.validate_jcal_token(
key, "recurrence rule part name", cls, path=[3, key]
)
key = cls._normalize_key(key)
value_type = cls.types.get(key, vText)
with JCalParsingError.reraise_with_path_added(3, key):
if isinstance(value, list):
recur[key.lower()] = values = []
for i, v in enumerate(value):
with JCalParsingError.reraise_with_path_added(i):
values.append(cls._parse_jcal_part_value(value_type, v))
else:
recur[key] = cls._parse_jcal_part_value(value_type, value)
until = recur.get("until")
if until is not None and not isinstance(until, list):
recur["until"] = [until]
return cls(recur, params=params)
@classmethod
def _parse_jcal_part_value(cls, value_type: type, jcal_value: Any) -> VPROPERTY:
"""Parse the jCal value of one recurrence rule part.
Raises:
~error.JCalParsingError: If a text value contains a part separator.
"""
value = value_type.parse_jcal_value(jcal_value)
if value_type is vText and _contains_part_separator(value):
raise JCalParsingError(
"The value must not contain a semicolon or comma.",
cls,
value=jcal_value,
)
return value
@property
def count(self) -> int | None:
"""The COUNT part of the recurrence rule.
This is the number of occurrences at which to range-bound the
recurrence, as an alternative to specifying an ``UNTIL`` value
(:rfc:`5545#section-3.3.10`). ``COUNT`` and ``UNTIL`` are mutually
exclusive; this accessor does not enforce that.
The RFC 5545 grammar for COUNT is ``1*DIGIT``, so ``0`` is a valid
value and is returned as such, not treated as missing.
If multiple values are present, the first one is returned. If the
value is missing or an empty sequence, ``None`` is returned.
Setting this to ``None`` deletes the value, as does ``del``.
Raises:
InvalidCalendar: if a value is present but cannot be read as an
int. Parsing rejects a non-numeric ``COUNT`` before it can be
stored, so this only happens when the value was assigned
directly (``recur["COUNT"] = "abc"``).
InvalidCalendar: when setting a negative value.
TypeError: when setting a value that is not an int.
Example:
.. code-block:: pycon
>>> from icalendar.prop import vRecur
>>> vRecur.from_ical("FREQ=DAILY;COUNT=10").count
10
>>> vRecur.from_ical("FREQ=DAILY;COUNT=0").count
0
>>> vRecur.from_ical("FREQ=DAILY").count is None
True
"""
values = self.get("COUNT")
if values is None or (isinstance(values, SEQUENCE_TYPES) and len(values) == 0):
return None
value = values[0] if isinstance(values, SEQUENCE_TYPES) else values
try:
return int(value)
except (TypeError, ValueError) as e:
# Matches how icalendar.attr.single_int_property surfaces a stored
# value that is not readable as an int.
raise InvalidCalendar("COUNT must be an int") from e
@count.setter
def count(self, value: int | None) -> None:
"""Set the COUNT part of the recurrence rule, or delete it if None."""
if value is None:
del self.count
return
if not isinstance(value, int) or isinstance(value, bool):
raise TypeError(f"count must be an int, got {value!r}")
if value < 0:
# RFC 5545's grammar for COUNT is `1*DIGIT`: unsigned digits only,
# so a negative value can never be a valid COUNT. 0 is valid
# (RFC 5545 doesn't say COUNT must be positive, only non-negative
# per its digit-only grammar), and is stored as such.
raise InvalidCalendar(f"COUNT must be >= 0, got {value}")
self["COUNT"] = [vNonNegativeInt(value)]
@count.deleter
def count(self) -> None:
"""Delete the COUNT part of the recurrence rule."""
self.pop("COUNT", None)
@property
def wkst(self) -> vWeekday | None:
"""The WKST part of the recurrence rule.
This defines the day on which the workweek starts. It is significant
when a weekly rule has an interval greater than one and when a yearly
rule uses ``BYWEEKNO`` (:rfc:`5545#section-3.3.10`). The value is one
of ``MO``, ``TU``, ``WE``, ``TH``, ``FR``, ``SA``, or ``SU``. When
omitted, RFC 5545 defines the default as ``MO``; this accessor returns
``None`` so callers can distinguish an omitted value from an explicit
one.
Because only one value is expected according to :rfc:`5545`,
if multiple values are present, the first one is returned. If the
value is missing or an empty sequence, ``None`` is returned.
Setting this to ``None`` deletes the value, as does ``del``.
Raises:
InvalidCalendar: if a value cannot be read or set as a weekday.
TypeError: when setting a value that is not a weekday string
or :class:`~icalendar.prop.recur.weekday.vWeekday`.
Example:
.. code-block:: pycon
>>> from icalendar.prop import vRecur
>>> vRecur("FREQ=WEEKLY;WKST=SU").wkst
'SU'
>>> vRecur("FREQ=WEEKLY").wkst is None
True
"""
values = self.get("WKST")
if values is None or (isinstance(values, SEQUENCE_TYPES) and len(values) == 0):
return None
value = values[0] if isinstance(values, SEQUENCE_TYPES) else values
if isinstance(value, vWeekday):
return value
try:
return vWeekday(value)
except (TypeError, ValueError) as e:
raise InvalidCalendar("WKST must be a weekday") from e
@wkst.setter
def wkst(self, value: str | vWeekday | None) -> None:
"""Set the WKST part of the recurrence rule, or delete it if None."""
if value is None:
del self.wkst
return
if not isinstance(value, (str, vWeekday)):
raise TypeError(f"wkst must be a weekday string or vWeekday, got {value!r}")
try:
weekday = value if isinstance(value, vWeekday) else vWeekday(value)
except ValueError as e:
raise InvalidCalendar("WKST must be a weekday") from e
# from_ical stores WKST as a one-item list via parse_type; keep
# that representation so assignment matches a parsed vRecur.
self["WKST"] = [weekday]
@wkst.deleter
def wkst(self) -> None:
"""Delete the WKST part of the recurrence rule."""
self.pop("WKST", None)
def __eq__(self, other: object) -> bool:
"""self == other"""
if not isinstance(other, vRecur):
return super().__eq__(other)
if self.keys() != other.keys():
return False
for key in self.keys():
v1 = self[key]
v2 = other[key]
if not isinstance(v1, SEQUENCE_TYPES):
v1 = [v1]
if not isinstance(v2, SEQUENCE_TYPES):
v2 = [v2]
if v1 != v2:
return False
return True
__hash__ = None
[docs]
@classmethod
@from_xcal_wrapper
def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
"""Parse xCal from :rfc:`6321`.
Parameters:
xml: The XML to parse or a parser.
Raises:
~error.XCalParsingError: If the provided xCal is invalid.
"""
if parser.tag != cls.default_value.lower():
self = cls.from_xcal(parser.parse_tag(cls.default_value))
self.params = params
return self
self = cls(params=params)
while not parser.is_finished():
child = parser.child
key = child.tag
v_prop = cls.types.get(key, vText)
from_xcal: Callable[[XCalParser], VPROPERTY] | None = getattr(
v_prop, "from_xcal_in_recur", None
)
if from_xcal is None:
from_xcal = v_prop.from_xcal
value = from_xcal(parser)
if v_prop is vText and _contains_part_separator(value):
raise XCalParsingError(
"Expected a value without a semicolon or comma", str(value), child
)
# Keep UNTIL as a raw date/time, as from_ical and from_jcal do.
parsed_value = value.dt if isinstance(value, vDDDTypes) else value
self.setdefault(key, []).append(parsed_value)
return self
[docs]
def to_xcal(self, element: Element) -> None:
"""Add the xCal representation of this property according to :rfc:`6321`."""
self.params.to_xcal(element)
recur_element = SubElement(element, self.default_value.lower())
for key, value in self.sorted_items():
value = value if isinstance(value, list) else [value]
for v in value:
if not hasattr(v, "to_xcal"):
v_prop = self.types.get(key, vText)
v = v_prop(v)
v.to_xcal(recur_element)
recur_element[-1].tag = key.lower()
__all__ = ["vRecur"]