1"""RECUR property type from :rfc:`5545`."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, Any, ClassVar
6from xml.etree.ElementTree import Element, SubElement
7
8from icalendar.caselessdict import CaselessDict
9from icalendar.compatibility import Self
10from icalendar.error import InvalidCalendar, JCalParsingError, XCalParsingError
11from icalendar.parser import Parameters
12from icalendar.parser.xcal.wrapper import from_xcal_wrapper
13from icalendar.parser_tools import DEFAULT_ENCODING, SEQUENCE_TYPES
14from icalendar.prop.dt import vDDDTypes
15from icalendar.prop.integer import vInt, vNonNegativeInt
16from icalendar.prop.recur.frequency import vFrequency
17from icalendar.prop.recur.month import vMonth
18from icalendar.prop.recur.skip import vSkip
19from icalendar.prop.recur.weekday import vWeekday
20from icalendar.prop.text import vText
21
22if TYPE_CHECKING:
23 from collections.abc import Callable
24
25 from icalendar.compatibility import Self
26 from icalendar.parser.xcal.base import XCalParser
27 from icalendar.prop import VPROPERTY
28
29
30def _contains_part_separator(value: str) -> bool:
31 """Whether a text value of a recurrence rule part contains a separator.
32
33 RECUR has no escaping, so a text value, such as that of ``RSCALE`` or an
34 unknown part, cannot contain the part separator ``;`` or the list
35 separator ``,``.
36
37 Parameters:
38 value: A value of one key/value pair in :class:`vRecur`.
39
40 Returns:
41 Whether a separator is present and the value must be rejected.
42 """
43 return ";" in value or "," in value
44
45
46class vRecur(CaselessDict):
47 """Recurrence definition.
48
49 The dateutil spelling ``byweekday`` is accepted as an alias for the
50 iCalendar ``BYDAY`` rule part. It is normalized to ``BYDAY`` when a
51 recurrence is parsed or serialized.
52
53 Property Name:
54 RRULE
55
56 Purpose:
57 This property defines a rule or repeating pattern for recurring events, to-dos,
58 journal entries, or time zone definitions.
59
60 Value Type:
61 RECUR
62
63 Property Parameters:
64 IANA and non-standard property parameters can be specified on this property.
65
66 Conformance:
67 This property can be specified in recurring "VEVENT", "VTODO", and "VJOURNAL"
68 calendar components as well as in the "STANDARD" and "DAYLIGHT" sub-components
69 of the "VTIMEZONE" calendar component, but it SHOULD NOT be specified more than once.
70 The recurrence set generated with multiple "RRULE" properties is undefined.
71
72 Description:
73 The recurrence rule, if specified, is used in computing the recurrence set.
74 The recurrence set is the complete set of recurrence instances for a calendar component.
75 The recurrence set is generated by considering the initial "DTSTART" property along
76 with the "RRULE", "RDATE", and "EXDATE" properties contained within the
77 recurring component. The "DTSTART" property defines the first instance in the
78 recurrence set. The "DTSTART" property value SHOULD be synchronized with the
79 recurrence rule, if specified. The recurrence set generated with a "DTSTART" property
80 value not synchronized with the recurrence rule is undefined.
81 The final recurrence set is generated by gathering all of the start DATE-TIME
82 values generated by any of the specified "RRULE" and "RDATE" properties, and then
83 excluding any start DATE-TIME values specified by "EXDATE" properties.
84 This implies that start DATE- TIME values specified by "EXDATE" properties take
85 precedence over those specified by inclusion properties (i.e., "RDATE" and "RRULE").
86 Where duplicate instances are generated by the "RRULE" and "RDATE" properties,
87 only one recurrence is considered. Duplicate instances are ignored.
88
89 The "DTSTART" property specified within the iCalendar object defines the first
90 instance of the recurrence. In most cases, a "DTSTART" property of DATE-TIME value
91 type used with a recurrence rule, should be specified as a date with local time
92 and time zone reference to make sure all the recurrence instances start at the
93 same local time regardless of time zone changes.
94
95 If the duration of the recurring component is specified with the "DTEND" or
96 "DUE" property, then the same exact duration will apply to all the members of the
97 generated recurrence set. Else, if the duration of the recurring component is
98 specified with the "DURATION" property, then the same nominal duration will apply
99 to all the members of the generated recurrence set and the exact duration of each
100 recurrence instance will depend on its specific start time. For example, recurrence
101 instances of a nominal duration of one day will have an exact duration of more or less
102 than 24 hours on a day where a time zone shift occurs. The duration of a specific
103 recurrence may be modified in an exception component or simply by using an
104 "RDATE" property of PERIOD value type.
105
106 Examples:
107 The following RRULE specifies daily events for 10 occurrences.
108
109 .. code-block:: ics
110
111 RRULE:FREQ=DAILY;COUNT=10
112
113 Below, we parse the RRULE ical string.
114
115 .. code-block:: pycon
116
117 >>> from icalendar.prop import vRecur
118 >>> rrule = vRecur.from_ical('FREQ=DAILY;COUNT=10')
119 >>> rrule
120 vRecur({'FREQ': ['DAILY'], 'COUNT': [10]})
121
122 You can choose to add an rrule to an :class:`icalendar.cal.Event` or
123 :class:`icalendar.cal.Todo`.
124
125 .. code-block:: pycon
126
127 >>> from icalendar import Event
128 >>> event = Event()
129 >>> event.add('RRULE', 'FREQ=DAILY;COUNT=10')
130 >>> event.rrules
131 [vRecur({'FREQ': ['DAILY'], 'COUNT': [10]})]
132 """
133
134 default_value: ClassVar[str] = "RECUR"
135 params: Parameters
136
137 frequencies = [
138 "SECONDLY",
139 "MINUTELY",
140 "HOURLY",
141 "DAILY",
142 "WEEKLY",
143 "MONTHLY",
144 "YEARLY",
145 ]
146
147 # Mac iCal ignores RRULEs where FREQ is not the first rule part.
148 # Sorts parts according to the order listed in RFC 5545, section 3.3.10.
149 canonical_order = (
150 "RSCALE",
151 "FREQ",
152 "UNTIL",
153 "COUNT",
154 "INTERVAL",
155 "BYSECOND",
156 "BYMINUTE",
157 "BYHOUR",
158 "BYDAY",
159 "BYMONTHDAY",
160 "BYYEARDAY",
161 "BYWEEKNO",
162 "BYMONTH",
163 "BYSETPOS",
164 "WKST",
165 "SKIP",
166 )
167
168 types: ClassVar[
169 dict[str, type[vInt | vMonth | vFrequency | vWeekday | vSkip | vText]]
170 ] = CaselessDict(
171 {
172 "COUNT": vNonNegativeInt,
173 "INTERVAL": vNonNegativeInt,
174 "BYSECOND": vNonNegativeInt,
175 "BYMINUTE": vNonNegativeInt,
176 "BYHOUR": vNonNegativeInt,
177 "BYWEEKNO": vInt,
178 "BYMONTHDAY": vInt,
179 "BYYEARDAY": vInt,
180 "BYMONTH": vMonth,
181 "UNTIL": vDDDTypes,
182 "BYSETPOS": vInt,
183 "WKST": vWeekday,
184 "BYDAY": vWeekday,
185 "FREQ": vFrequency,
186 "SKIP": vSkip, # RFC 7529
187 "RSCALE": vText, # RFC 7529
188 }
189 )
190
191 # for reproducible serialization:
192 # RULE: if and only if it can be a list it will be a list
193 # look up in RFC
194 jcal_not_a_list = {"FREQ", "UNTIL", "COUNT", "INTERVAL", "WKST", "SKIP", "RSCALE"}
195
196 def __init__(
197 self, *args: Any, params: dict[str, Any] | None = None, **kwargs: Any
198 ) -> None:
199 if args and isinstance(args[0], str):
200 # we have a string as an argument.
201 args = (self.from_ical(args[0]),) + args[1:]
202 for k, v in kwargs.items():
203 if not isinstance(v, SEQUENCE_TYPES):
204 kwargs[k] = [v]
205 super().__init__(*args, **kwargs)
206 self._normalize_byweekday()
207 self.params = Parameters(params)
208
209 @staticmethod
210 def _normalize_key(key: str) -> str:
211 """Return the iCalendar name for a recurrence rule part."""
212 return "BYDAY" if key.upper() == "BYWEEKDAY" else key
213
214 def _normalize_byweekday(self) -> None:
215 """Normalize the dateutil spelling to the RFC 5545 spelling."""
216 if "BYWEEKDAY" in self:
217 if "BYDAY" in self:
218 raise ValueError("BYDAY and BYWEEKDAY cannot both be specified")
219 self["BYDAY"] = self.pop("BYWEEKDAY")
220
221 def _normalized(self) -> Self:
222 """Return a recurrence with canonical rule-part names."""
223 return type(self)(self) if "BYWEEKDAY" in self else self
224
225 def to_ical(self):
226 result = []
227 for key, vals in self._normalized().sorted_items():
228 typ = self.types.get(key, vText)
229 if not isinstance(vals, SEQUENCE_TYPES):
230 vals = [vals]
231 param_vals = b",".join(typ(val).to_ical() for val in vals)
232
233 # CaselessDict keys are always unicode
234 param_key = key.encode(DEFAULT_ENCODING)
235 result.append(param_key + b"=" + param_vals)
236
237 return b";".join(result)
238
239 @classmethod
240 def parse_type(cls, key, values):
241 # integers
242 key = cls._normalize_key(key)
243 parser = cls.types.get(key, vText)
244 return [parser.from_ical(v) for v in values.split(",")]
245
246 @classmethod
247 def from_ical(cls, ical: str):
248 if isinstance(ical, cls):
249 return ical
250 try:
251 recur = cls()
252 for pairs in ical.split(";"):
253 try:
254 key, vals = pairs.split("=")
255 except ValueError:
256 # E.g. incorrect trailing semicolon, like (issue #157):
257 # FREQ=YEARLY;BYMONTH=11;BYDAY=1SU;
258 continue
259 key = cls._normalize_key(key)
260 recur[key] = cls.parse_type(key, vals)
261 return cls(recur)
262 except ValueError:
263 raise
264 except Exception as e:
265 raise ValueError(f"Error in recurrence rule: {ical}") from e
266
267 @classmethod
268 def examples(cls) -> list[Self]:
269 """Examples of vRecur."""
270 return [cls.from_ical("FREQ=DAILY;COUNT=10")]
271
272 from icalendar.param import VALUE
273
274 def to_jcal(self, name: str) -> list:
275 """The jCal representation of this property according to :rfc:`7265`."""
276 recur = {}
277 for k, v in self._normalized().sorted_items():
278 key = k.lower()
279 if key.upper() in self.jcal_not_a_list:
280 value = v[0] if isinstance(v, list) and len(v) == 1 else v
281 elif not isinstance(v, list):
282 value = [v]
283 else:
284 value = v
285 recur[key] = value
286 if "until" in recur:
287 until = recur["until"]
288 until_jcal = vDDDTypes(until).to_jcal("until")
289 recur["until"] = until_jcal[-1]
290 return [name, self.params.to_jcal(), self.VALUE.lower(), recur]
291
292 @classmethod
293 def from_jcal(cls, jcal_property: list) -> Self:
294 """Parse jCal from :rfc:`7265`.
295
296 Parameters:
297 jcal_property: The jCal property to parse.
298
299 Raises:
300 ~icalendar.error.JCalParsingError: If the provided jCal is invalid.
301 """
302 JCalParsingError.validate_property(jcal_property, cls)
303 params = Parameters.from_jcal_property(jcal_property)
304 if not isinstance(jcal_property[3], dict) or not all(
305 isinstance(k, str) for k in jcal_property[3]
306 ):
307 raise JCalParsingError(
308 "The recurrence rule must be a mapping with string keys.",
309 cls,
310 3,
311 value=jcal_property[3],
312 )
313 recur = {}
314 for key, value in jcal_property[3].items():
315 JCalParsingError.validate_jcal_token(
316 key, "recurrence rule part name", cls, path=[3, key]
317 )
318 key = cls._normalize_key(key)
319 value_type = cls.types.get(key, vText)
320 with JCalParsingError.reraise_with_path_added(3, key):
321 if isinstance(value, list):
322 recur[key.lower()] = values = []
323 for i, v in enumerate(value):
324 with JCalParsingError.reraise_with_path_added(i):
325 values.append(cls._parse_jcal_part_value(value_type, v))
326 else:
327 recur[key] = cls._parse_jcal_part_value(value_type, value)
328 until = recur.get("until")
329 if until is not None and not isinstance(until, list):
330 recur["until"] = [until]
331 return cls(recur, params=params)
332
333 @classmethod
334 def _parse_jcal_part_value(cls, value_type: type, jcal_value: Any) -> VPROPERTY:
335 """Parse the jCal value of one recurrence rule part.
336
337 Raises:
338 ~error.JCalParsingError: If a text value contains a part separator.
339 """
340 value = value_type.parse_jcal_value(jcal_value)
341 if value_type is vText and _contains_part_separator(value):
342 raise JCalParsingError(
343 "The value must not contain a semicolon or comma.",
344 cls,
345 value=jcal_value,
346 )
347 return value
348
349 @property
350 def count(self) -> int | None:
351 """The COUNT part of the recurrence rule.
352
353 This is the number of occurrences at which to range-bound the
354 recurrence, as an alternative to specifying an ``UNTIL`` value
355 (:rfc:`5545#section-3.3.10`). ``COUNT`` and ``UNTIL`` are mutually
356 exclusive; this accessor does not enforce that.
357
358 The RFC 5545 grammar for COUNT is ``1*DIGIT``, so ``0`` is a valid
359 value and is returned as such, not treated as missing.
360
361 If multiple values are present, the first one is returned. If the
362 value is missing or an empty sequence, ``None`` is returned.
363
364 Setting this to ``None`` deletes the value, as does ``del``.
365
366 Raises:
367 InvalidCalendar: if a value is present but cannot be read as an
368 int. Parsing rejects a non-numeric ``COUNT`` before it can be
369 stored, so this only happens when the value was assigned
370 directly (``recur["COUNT"] = "abc"``).
371 InvalidCalendar: when setting a negative value.
372 TypeError: when setting a value that is not an int.
373
374 Example:
375 .. code-block:: pycon
376
377 >>> from icalendar.prop import vRecur
378 >>> vRecur.from_ical("FREQ=DAILY;COUNT=10").count
379 10
380 >>> vRecur.from_ical("FREQ=DAILY;COUNT=0").count
381 0
382 >>> vRecur.from_ical("FREQ=DAILY").count is None
383 True
384 """
385 values = self.get("COUNT")
386 if values is None or (isinstance(values, SEQUENCE_TYPES) and len(values) == 0):
387 return None
388 value = values[0] if isinstance(values, SEQUENCE_TYPES) else values
389 try:
390 return int(value)
391 except (TypeError, ValueError) as e:
392 # Matches how icalendar.attr.single_int_property surfaces a stored
393 # value that is not readable as an int.
394 raise InvalidCalendar("COUNT must be an int") from e
395
396 @count.setter
397 def count(self, value: int | None) -> None:
398 """Set the COUNT part of the recurrence rule, or delete it if None."""
399 if value is None:
400 del self.count
401 return
402 if not isinstance(value, int) or isinstance(value, bool):
403 raise TypeError(f"count must be an int, got {value!r}")
404 if value < 0:
405 # RFC 5545's grammar for COUNT is `1*DIGIT`: unsigned digits only,
406 # so a negative value can never be a valid COUNT. 0 is valid
407 # (RFC 5545 doesn't say COUNT must be positive, only non-negative
408 # per its digit-only grammar), and is stored as such.
409 raise InvalidCalendar(f"COUNT must be >= 0, got {value}")
410 self["COUNT"] = [vNonNegativeInt(value)]
411
412 @count.deleter
413 def count(self) -> None:
414 """Delete the COUNT part of the recurrence rule."""
415 self.pop("COUNT", None)
416
417 @property
418 def wkst(self) -> vWeekday | None:
419 """The WKST part of the recurrence rule.
420
421 This defines the day on which the workweek starts. It is significant
422 when a weekly rule has an interval greater than one and when a yearly
423 rule uses ``BYWEEKNO`` (:rfc:`5545#section-3.3.10`). The value is one
424 of ``MO``, ``TU``, ``WE``, ``TH``, ``FR``, ``SA``, or ``SU``. When
425 omitted, RFC 5545 defines the default as ``MO``; this accessor returns
426 ``None`` so callers can distinguish an omitted value from an explicit
427 one.
428
429 Because only one value is expected according to :rfc:`5545`,
430 if multiple values are present, the first one is returned. If the
431 value is missing or an empty sequence, ``None`` is returned.
432
433 Setting this to ``None`` deletes the value, as does ``del``.
434
435 Raises:
436 InvalidCalendar: if a value cannot be read or set as a weekday.
437 TypeError: when setting a value that is not a weekday string
438 or :class:`~icalendar.prop.recur.weekday.vWeekday`.
439
440 Example:
441 .. code-block:: pycon
442
443 >>> from icalendar.prop import vRecur
444 >>> vRecur("FREQ=WEEKLY;WKST=SU").wkst
445 'SU'
446 >>> vRecur("FREQ=WEEKLY").wkst is None
447 True
448 """
449 values = self.get("WKST")
450 if values is None or (isinstance(values, SEQUENCE_TYPES) and len(values) == 0):
451 return None
452 value = values[0] if isinstance(values, SEQUENCE_TYPES) else values
453 if isinstance(value, vWeekday):
454 return value
455 try:
456 return vWeekday(value)
457 except (TypeError, ValueError) as e:
458 raise InvalidCalendar("WKST must be a weekday") from e
459
460 @wkst.setter
461 def wkst(self, value: str | vWeekday | None) -> None:
462 """Set the WKST part of the recurrence rule, or delete it if None."""
463 if value is None:
464 del self.wkst
465 return
466 if not isinstance(value, (str, vWeekday)):
467 raise TypeError(f"wkst must be a weekday string or vWeekday, got {value!r}")
468 try:
469 weekday = value if isinstance(value, vWeekday) else vWeekday(value)
470 except ValueError as e:
471 raise InvalidCalendar("WKST must be a weekday") from e
472 # from_ical stores WKST as a one-item list via parse_type; keep
473 # that representation so assignment matches a parsed vRecur.
474 self["WKST"] = [weekday]
475
476 @wkst.deleter
477 def wkst(self) -> None:
478 """Delete the WKST part of the recurrence rule."""
479 self.pop("WKST", None)
480
481 def __eq__(self, other: object) -> bool:
482 """self == other"""
483 if not isinstance(other, vRecur):
484 return super().__eq__(other)
485 if self.keys() != other.keys():
486 return False
487 for key in self.keys():
488 v1 = self[key]
489 v2 = other[key]
490 if not isinstance(v1, SEQUENCE_TYPES):
491 v1 = [v1]
492 if not isinstance(v2, SEQUENCE_TYPES):
493 v2 = [v2]
494 if v1 != v2:
495 return False
496 return True
497
498 __hash__ = None
499
500 @classmethod
501 @from_xcal_wrapper
502 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
503 """Parse xCal from :rfc:`6321`.
504
505 Parameters:
506 xml: The XML to parse or a parser.
507
508 Raises:
509 ~error.XCalParsingError: If the provided xCal is invalid.
510 """
511 if parser.tag != cls.default_value.lower():
512 self = cls.from_xcal(parser.parse_tag(cls.default_value))
513 self.params = params
514 return self
515 self = cls(params=params)
516 while not parser.is_finished():
517 child = parser.child
518 key = child.tag
519 v_prop = cls.types.get(key, vText)
520 from_xcal: Callable[[XCalParser], VPROPERTY] | None = getattr(
521 v_prop, "from_xcal_in_recur", None
522 )
523 if from_xcal is None:
524 from_xcal = v_prop.from_xcal
525 value = from_xcal(parser)
526 if v_prop is vText and _contains_part_separator(value):
527 raise XCalParsingError(
528 "Expected a value without a semicolon or comma", str(value), child
529 )
530 # Keep UNTIL as a raw date/time, as from_ical and from_jcal do.
531 parsed_value = value.dt if isinstance(value, vDDDTypes) else value
532 self.setdefault(key, []).append(parsed_value)
533 return self
534
535 def to_xcal(self, element: Element) -> None:
536 """Add the xCal representation of this property according to :rfc:`6321`."""
537 self.params.to_xcal(element)
538 recur_element = SubElement(element, self.default_value.lower())
539 for key, value in self.sorted_items():
540 value = value if isinstance(value, list) else [value]
541 for v in value:
542 if not hasattr(v, "to_xcal"):
543 v_prop = self.types.get(key, vText)
544 v = v_prop(v)
545 v.to_xcal(recur_element)
546 recur_element[-1].tag = key.lower()
547
548
549__all__ = ["vRecur"]