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