Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/icalendar/prop/recur/recur.py: 41%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

229 statements  

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