1"""The base for :rfc:`5545` components."""
2
3from __future__ import annotations
4
5from datetime import date, datetime, time, timedelta, timezone
6from typing import TYPE_CHECKING, ClassVar
7
8from icalendar.attr import (
9 CONCEPTS_TYPE_SETTER,
10 LINKS_TYPE_SETTER,
11 RELATED_TO_TYPE_SETTER,
12 comments_property,
13 concepts_property,
14 links_property,
15 refids_property,
16 related_to_property,
17 single_utc_property,
18 uid_property,
19)
20from icalendar.cal.component_factory import ComponentFactory
21from icalendar.caselessdict import CaselessDict
22from icalendar.error import InvalidCalendar
23from icalendar.parser import Contentline, Contentlines, Parameters, q_join, q_split
24from icalendar.parser_tools import DEFAULT_ENCODING
25from icalendar.prop import TypesFactory, vDDDLists, vText
26from icalendar.timezone import tzp
27from icalendar.tools import is_date
28
29if TYPE_CHECKING:
30 from icalendar.compatibility import Self
31
32_marker = []
33
34
35class Component(CaselessDict):
36 """Base class for calendar components.
37
38 Component is the base object for calendar, Event and the other
39 components defined in :rfc:`5545`. Normally you will not use this class
40 directly, but rather one of the subclasses.
41
42 Attributes:
43 name: The name of the component. Example: ``VCALENDAR``.
44 required: These properties are required.
45 singletons: These properties must only appear once.
46 multiple: These properties may occur more than once.
47 exclusive: These properties are mutually exclusive.
48 inclusive: If the first in a tuple occurs, the second one must also occur.
49 ignore_exceptions: If True, and we cannot parse this
50 component, we will silently ignore it, rather than let the
51 exception propagate upwards.
52 types_factory: Factory for property types
53 """
54
55 name = None # should be defined in each component
56 required = () # These properties are required
57 singletons = () # These properties must only appear once
58 multiple = () # may occur more than once
59 exclusive = () # These properties are mutually exclusive
60 inclusive: (
61 tuple[str] | tuple[tuple[str, str]]
62 ) = () # if any occurs the other(s) MUST occur
63 # ('duration', 'repeat')
64 ignore_exceptions = False # if True, and we cannot parse this
65 # component, we will silently ignore
66 # it, rather than let the exception
67 # propagate upwards
68 # not_compliant = [''] # List of non-compliant properties.
69
70 types_factory = TypesFactory()
71 _components_factory: ClassVar[ComponentFactory | None] = None
72
73 @classmethod
74 def get_component_class(cls, name: str) -> type[Component]:
75 """Return a component with this name.
76
77 Arguments:
78 name: Name of the component, i.e. ``VCALENDAR``
79 """
80 if cls._components_factory is None:
81 cls._components_factory = ComponentFactory()
82 return cls._components_factory.get(name, Component)
83
84 @staticmethod
85 def _infer_value_type(
86 value: date | datetime | timedelta | time | tuple | list,
87 ) -> str | None:
88 """Infer the ``VALUE`` parameter from a Python type.
89
90 Args:
91 value: Python native type, one of :py:class:`date`, :py:mod:`datetime`,
92 :py:class:`timedelta`, :py:mod:`time`, :py:class:`tuple`,
93 or :py:class:`list`.
94
95 Returns:
96 str or None: The ``VALUE`` parameter string, for example, "DATE",
97 "TIME", or other string, or ``None``
98 if no specific ``VALUE`` is needed.
99 """
100 if isinstance(value, list):
101 if not value:
102 return None
103 # Check if ALL items are date (but not datetime)
104 if all(is_date(item) for item in value):
105 return "DATE"
106 # Check if ALL items are time
107 if all(isinstance(item, time) for item in value):
108 return "TIME"
109 # Mixed types or other types - don't infer
110 return None
111 if is_date(value):
112 return "DATE"
113 if isinstance(value, time):
114 return "TIME"
115 # Don't infer PERIOD - it's too risky and vPeriod already handles it
116 return None
117
118 def __init__(self, *args, **kwargs):
119 """Set keys to upper for initial dict."""
120 super().__init__(*args, **kwargs)
121 # set parameters here for properties that use non-default values
122 self.subcomponents: list[Component] = [] # Components can be nested.
123 self.errors = [] # If we ignored exception(s) while
124 # parsing a property, contains error strings
125
126 def __bool__(self):
127 """Returns True, CaselessDict would return False if it had no items."""
128 return True
129
130 def is_empty(self):
131 """Returns True if Component has no items or subcomponents, else False."""
132 return bool(not list(self.values()) + self.subcomponents)
133
134 #############################
135 # handling of property values
136
137 @classmethod
138 def _encode(cls, name, value, parameters=None, encode=1):
139 """Encode values to icalendar property values.
140
141 :param name: Name of the property.
142 :type name: string
143
144 :param value: Value of the property. Either of a basic Python type of
145 any of the icalendar's own property types.
146 :type value: Python native type or icalendar property type.
147
148 :param parameters: Property parameter dictionary for the value. Only
149 available, if encode is set to True.
150 :type parameters: Dictionary
151
152 :param encode: True, if the value should be encoded to one of
153 icalendar's own property types (Fallback is "vText")
154 or False, if not.
155 :type encode: Boolean
156
157 :returns: icalendar property value
158 """
159 if not encode:
160 return value
161 if isinstance(value, cls.types_factory.all_types):
162 # Don't encode already encoded values.
163 obj = value
164 else:
165 # Extract VALUE parameter if present, or infer it from the Python type
166 value_param = None
167 if parameters and "VALUE" in parameters:
168 value_param = parameters["VALUE"]
169 elif not isinstance(value, cls.types_factory.all_types):
170 inferred = cls._infer_value_type(value)
171 if inferred:
172 value_param = inferred
173 # Auto-set the VALUE parameter
174 if parameters is None:
175 parameters = {}
176 if "VALUE" not in parameters:
177 parameters["VALUE"] = inferred
178
179 klass = cls.types_factory.for_property(name, value_param)
180 obj = klass(value)
181 if parameters:
182 if not hasattr(obj, "params"):
183 obj.params = Parameters()
184 for key, item in parameters.items():
185 if item is None:
186 if key in obj.params:
187 del obj.params[key]
188 else:
189 obj.params[key] = item
190 return obj
191
192 def add(
193 self,
194 name: str,
195 value,
196 parameters: dict[str, str] | Parameters = None,
197 encode: bool = True, # noqa: FBT001
198 ):
199 """Add a property.
200
201 :param name: Name of the property.
202 :type name: string
203
204 :param value: Value of the property. Either of a basic Python type of
205 any of the icalendar's own property types.
206 :type value: Python native type or icalendar property type.
207
208 :param parameters: Property parameter dictionary for the value. Only
209 available, if encode is set to True.
210 :type parameters: Dictionary
211
212 :param encode: True, if the value should be encoded to one of
213 icalendar's own property types (Fallback is "vText")
214 or False, if not.
215 :type encode: Boolean
216
217 :returns: None
218 """
219 if isinstance(value, datetime) and name.lower() in (
220 "dtstamp",
221 "created",
222 "last-modified",
223 ):
224 # RFC expects UTC for those... force value conversion.
225 value = tzp.localize_utc(value)
226
227 # encode value
228 if (
229 encode
230 and isinstance(value, list)
231 and name.lower() not in ["rdate", "exdate", "categories"]
232 ):
233 # Individually convert each value to an ical type except rdate and
234 # exdate, where lists of dates might be passed to vDDDLists.
235 value = [self._encode(name, v, parameters, encode) for v in value]
236 else:
237 value = self._encode(name, value, parameters, encode)
238
239 # set value
240 if name in self:
241 # If property already exists, append it.
242 oldval = self[name]
243 if isinstance(oldval, list):
244 if isinstance(value, list):
245 value = oldval + value
246 else:
247 oldval.append(value)
248 value = oldval
249 else:
250 value = [oldval, value]
251 self[name] = value
252
253 def _decode(self, name, value):
254 """Internal for decoding property values."""
255
256 # TODO: Currently the decoded method calls the icalendar.prop instances
257 # from_ical. We probably want to decode properties into Python native
258 # types here. But when parsing from an ical string with from_ical, we
259 # want to encode the string into a real icalendar.prop property.
260 if isinstance(value, vDDDLists):
261 # TODO: Workaround unfinished decoding
262 return value
263 decoded = self.types_factory.from_ical(name, value)
264 # TODO: remove when proper decoded is implemented in every prop.* class
265 # Workaround to decode vText properly
266 if isinstance(decoded, vText):
267 decoded = decoded.encode(DEFAULT_ENCODING)
268 return decoded
269
270 def decoded(self, name, default=_marker):
271 """Returns decoded value of property."""
272 # XXX: fail. what's this function supposed to do in the end?
273 # -rnix
274
275 if name in self:
276 value = self[name]
277 if isinstance(value, list):
278 return [self._decode(name, v) for v in value]
279 return self._decode(name, value)
280 if default is _marker:
281 raise KeyError(name)
282 return default
283
284 ########################################################################
285 # Inline values. A few properties have multiple values inlined in in one
286 # property line. These methods are used for splitting and joining these.
287
288 def get_inline(self, name, decode=1):
289 """Returns a list of values (split on comma)."""
290 vals = [v.strip('" ') for v in q_split(self[name])]
291 if decode:
292 return [self._decode(name, val) for val in vals]
293 return vals
294
295 def set_inline(self, name, values, encode=1):
296 """Converts a list of values into comma separated string and sets value
297 to that.
298 """
299 if encode:
300 values = [self._encode(name, value, encode=1) for value in values]
301 self[name] = self.types_factory["inline"](q_join(values))
302
303 #########################
304 # Handling of components
305
306 def add_component(self, component: Component):
307 """Add a subcomponent to this component."""
308 self.subcomponents.append(component)
309
310 def _walk(self, name, select):
311 """Walk to given component."""
312 result = []
313 if (name is None or self.name == name) and select(self):
314 result.append(self)
315 for subcomponent in self.subcomponents:
316 result += subcomponent._walk(name, select) # noqa: SLF001
317 return result
318
319 def walk(self, name=None, select=lambda _: True) -> list[Component]:
320 """Recursively traverses component and subcomponents. Returns sequence
321 of same. If name is passed, only components with name will be returned.
322
323 :param name: The name of the component or None such as ``VEVENT``.
324 :param select: A function that takes the component as first argument
325 and returns True/False.
326 :returns: A list of components that match.
327 :rtype: list[Component]
328 """
329 if name is not None:
330 name = name.upper()
331 return self._walk(name, select)
332
333 #####################
334 # Generation
335
336 def property_items(
337 self,
338 recursive=True,
339 sorted: bool = True, # noqa: A002, FBT001
340 ) -> list[tuple[str, object]]:
341 """Returns properties in this component and subcomponents as:
342 [(name, value), ...]
343 """
344 v_text = self.types_factory["text"]
345 properties = [("BEGIN", v_text(self.name).to_ical())]
346 property_names = self.sorted_keys() if sorted else self.keys()
347
348 for name in property_names:
349 values = self[name]
350 if isinstance(values, list):
351 # normally one property is one line
352 for value in values:
353 properties.append((name, value))
354 else:
355 properties.append((name, values))
356 if recursive:
357 # recursion is fun!
358 for subcomponent in self.subcomponents:
359 properties += subcomponent.property_items(sorted=sorted)
360 properties.append(("END", v_text(self.name).to_ical()))
361 return properties
362
363 @classmethod
364 def from_ical(cls, st, multiple: bool = False) -> Self | list[Self]: # noqa: FBT001
365 """Populates the component recursively from a string."""
366 stack = [] # a stack of components
367 comps = []
368 for line in Contentlines.from_ical(st): # raw parsing
369 if not line:
370 continue
371
372 try:
373 name, params, vals = line.parts()
374 except ValueError as e:
375 # if unable to parse a line within a component
376 # that ignores exceptions, mark the component
377 # as broken and skip the line. otherwise raise.
378 component = stack[-1] if stack else None
379 if not component or not component.ignore_exceptions:
380 raise
381 component.errors.append((None, str(e)))
382 continue
383
384 uname = name.upper()
385 # check for start of component
386 if uname == "BEGIN":
387 # try and create one of the components defined in the spec,
388 # otherwise get a general Components for robustness.
389 c_name = vals.upper()
390 c_class = cls.get_component_class(c_name)
391 # If component factory cannot resolve ``c_name``, the generic
392 # ``Component`` class is used which does not have the name set.
393 # That's opposed to the usage of ``cls``, which represents a
394 # more concrete subclass with a name set (e.g. VCALENDAR).
395 component = c_class()
396 if not getattr(component, "name", ""): # undefined components
397 component.name = c_name
398 stack.append(component)
399 # check for end of event
400 elif uname == "END":
401 # we are done adding properties to this component
402 # so pop it from the stack and add it to the new top.
403 if not stack:
404 # The stack is currently empty, the input must be invalid
405 raise ValueError("END encountered without an accompanying BEGIN!")
406
407 component = stack.pop()
408 if not stack: # we are at the end
409 comps.append(component)
410 else:
411 stack[-1].add_component(component)
412 if vals == "VTIMEZONE" and "TZID" in component:
413 tzp.cache_timezone_component(component)
414 # we are adding properties to the current top of the stack
415 else:
416 # Extract VALUE parameter if present
417 value_param = params.get("VALUE") if params else None
418 factory = cls.types_factory.for_property(name, value_param)
419 component = stack[-1] if stack else None
420 if not component:
421 # only accept X-COMMENT at the end of the .ics file
422 # ignore these components in parsing
423 if uname == "X-COMMENT":
424 break
425 raise ValueError(
426 f'Property "{name}" does not have a parent component.'
427 )
428 datetime_names = (
429 "DTSTART",
430 "DTEND",
431 "RECURRENCE-ID",
432 "DUE",
433 "RDATE",
434 "EXDATE",
435 )
436 try:
437 if name == "FREEBUSY":
438 vals = vals.split(",")
439 if "TZID" in params:
440 parsed_components = [
441 factory(factory.from_ical(val, params["TZID"]))
442 for val in vals
443 ]
444 else:
445 parsed_components = [
446 factory(factory.from_ical(val)) for val in vals
447 ]
448 elif name in datetime_names and "TZID" in params:
449 parsed_components = [
450 factory(factory.from_ical(vals, params["TZID"]))
451 ]
452 # Workaround broken ICS files with empty RDATE
453 elif name == "RDATE" and vals == "":
454 parsed_components = []
455 else:
456 parsed_components = [factory(factory.from_ical(vals))]
457 except ValueError as e:
458 if not component.ignore_exceptions:
459 raise
460 component.errors.append((uname, str(e)))
461 else:
462 for parsed_component in parsed_components:
463 parsed_component.params = params
464 component.add(name, parsed_component, encode=0)
465
466 if multiple:
467 return comps
468 if len(comps) > 1:
469 raise ValueError(
470 cls._format_error(
471 "Found multiple components where only one is allowed", st
472 )
473 )
474 if len(comps) < 1:
475 raise ValueError(
476 cls._format_error(
477 "Found no components where exactly one is required", st
478 )
479 )
480 return comps[0]
481
482 @staticmethod
483 def _format_error(error_description, bad_input, elipsis="[...]"):
484 # there's three character more in the error, ie. ' ' x2 and a ':'
485 max_error_length = 100 - 3
486 if len(error_description) + len(bad_input) + len(elipsis) > max_error_length:
487 truncate_to = max_error_length - len(error_description) - len(elipsis)
488 return f"{error_description}: {bad_input[:truncate_to]} {elipsis}"
489 return f"{error_description}: {bad_input}"
490
491 def content_line(self, name, value, sorted: bool = True): # noqa: A002, FBT001
492 """Returns property as content line."""
493 params = getattr(value, "params", Parameters())
494 return Contentline.from_parts(name, params, value, sorted=sorted)
495
496 def content_lines(self, sorted: bool = True): # noqa: A002, FBT001
497 """Converts the Component and subcomponents into content lines."""
498 contentlines = Contentlines()
499 for name, value in self.property_items(sorted=sorted):
500 cl = self.content_line(name, value, sorted=sorted)
501 contentlines.append(cl)
502 contentlines.append("") # remember the empty string in the end
503 return contentlines
504
505 def to_ical(self, sorted: bool = True): # noqa: A002, FBT001
506 """
507 :param sorted: Whether parameters and properties should be
508 lexicographically sorted.
509 """
510
511 content_lines = self.content_lines(sorted=sorted)
512 return content_lines.to_ical()
513
514 def __repr__(self):
515 """String representation of class with all of it's subcomponents."""
516 subs = ", ".join(str(it) for it in self.subcomponents)
517 return (
518 f"{self.name or type(self).__name__}"
519 f"({dict(self)}{', ' + subs if subs else ''})"
520 )
521
522 def __eq__(self, other):
523 if len(self.subcomponents) != len(other.subcomponents):
524 return False
525
526 properties_equal = super().__eq__(other)
527 if not properties_equal:
528 return False
529
530 # The subcomponents might not be in the same order,
531 # neither there's a natural key we can sort the subcomponents by nor
532 # are the subcomponent types hashable, so we cant put them in a set to
533 # check for set equivalence. We have to iterate over the subcomponents
534 # and look for each of them in the list.
535 for subcomponent in self.subcomponents:
536 if subcomponent not in other.subcomponents:
537 return False
538
539 return True
540
541 DTSTAMP = stamp = single_utc_property(
542 "DTSTAMP",
543 """RFC 5545:
544
545 Conformance: This property MUST be included in the "VEVENT",
546 "VTODO", "VJOURNAL", or "VFREEBUSY" calendar components.
547
548 Description: In the case of an iCalendar object that specifies a
549 "METHOD" property, this property specifies the date and time that
550 the instance of the iCalendar object was created. In the case of
551 an iCalendar object that doesn't specify a "METHOD" property, this
552 property specifies the date and time that the information
553 associated with the calendar component was last revised in the
554 calendar store.
555
556 The value MUST be specified in the UTC time format.
557
558 In the case of an iCalendar object that doesn't specify a "METHOD"
559 property, this property is equivalent to the "LAST-MODIFIED"
560 property.
561 """,
562 )
563 LAST_MODIFIED = single_utc_property(
564 "LAST-MODIFIED",
565 """RFC 5545:
566
567 Purpose: This property specifies the date and time that the
568 information associated with the calendar component was last
569 revised in the calendar store.
570
571 Note: This is analogous to the modification date and time for a
572 file in the file system.
573
574 Conformance: This property can be specified in the "VEVENT",
575 "VTODO", "VJOURNAL", or "VTIMEZONE" calendar components.
576 """,
577 )
578
579 @property
580 def last_modified(self) -> datetime:
581 """Datetime when the information associated with the component was last revised.
582
583 Since :attr:`LAST_MODIFIED` is an optional property,
584 this returns :attr:`DTSTAMP` if :attr:`LAST_MODIFIED` is not set.
585 """
586 return self.LAST_MODIFIED or self.DTSTAMP
587
588 @last_modified.setter
589 def last_modified(self, value):
590 self.LAST_MODIFIED = value
591
592 @last_modified.deleter
593 def last_modified(self):
594 del self.LAST_MODIFIED
595
596 @property
597 def created(self) -> datetime:
598 """Datetime when the information associated with the component was created.
599
600 Since :attr:`CREATED` is an optional property,
601 this returns :attr:`DTSTAMP` if :attr:`CREATED` is not set.
602 """
603 return self.CREATED or self.DTSTAMP
604
605 @created.setter
606 def created(self, value):
607 self.CREATED = value
608
609 @created.deleter
610 def created(self):
611 del self.CREATED
612
613 def is_thunderbird(self) -> bool:
614 """Whether this component has attributes that indicate that Mozilla Thunderbird created it.""" # noqa: E501
615 return any(attr.startswith("X-MOZ-") for attr in self.keys())
616
617 @staticmethod
618 def _utc_now():
619 """Return now as UTC value."""
620 return datetime.now(timezone.utc)
621
622 uid = uid_property
623 comments = comments_property
624 links = links_property
625 related_to = related_to_property
626 concepts = concepts_property
627 refids = refids_property
628
629 CREATED = single_utc_property(
630 "CREATED",
631 """
632 CREATED specifies the date and time that the calendar
633 information was created by the calendar user agent in the calendar
634 store.
635
636 Conformance:
637 The property can be specified once in "VEVENT",
638 "VTODO", or "VJOURNAL" calendar components. The value MUST be
639 specified as a date with UTC time.
640
641 """,
642 )
643
644 _validate_new = True
645
646 @staticmethod
647 def _validate_start_and_end(start, end):
648 """This validates start and end.
649
650 Raises:
651 InvalidCalendar: If the information is not valid
652 """
653 if start is None or end is None:
654 return
655 if start > end:
656 raise InvalidCalendar("end must be after start")
657
658 @classmethod
659 def new(
660 cls,
661 created: date | None = None,
662 comments: list[str] | str | None = None,
663 concepts: CONCEPTS_TYPE_SETTER = None,
664 last_modified: date | None = None,
665 links: LINKS_TYPE_SETTER = None,
666 refids: list[str] | str | None = None,
667 related_to: RELATED_TO_TYPE_SETTER = None,
668 stamp: date | None = None,
669 ) -> Component:
670 """Create a new component.
671
672 Arguments:
673 comments: The :attr:`comments` of the component.
674 concepts: The :attr:`concepts` of the component.
675 created: The :attr:`created` of the component.
676 last_modified: The :attr:`last_modified` of the component.
677 links: The :attr:`links` of the component.
678 related_to: The :attr:`related_to` of the component.
679 stamp: The :attr:`DTSTAMP` of the component.
680
681 Raises:
682 InvalidCalendar: If the content is not valid according to :rfc:`5545`.
683
684 .. warning:: As time progresses, we will be stricter with the validation.
685 """
686 component = cls()
687 component.DTSTAMP = stamp
688 component.created = created
689 component.last_modified = last_modified
690 component.comments = comments
691 component.links = links
692 component.related_to = related_to
693 component.concepts = concepts
694 component.refids = refids
695 return component
696
697
698__all__ = ["Component"]