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

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

204 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 

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