Source code for icalendar.prop.recur.recur

"""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"]