1"""The base for :rfc:`5545` components."""
2
3from __future__ import annotations
4
5import json
6from copy import deepcopy
7from dataclasses import dataclass
8from datetime import date, datetime, time, timedelta, timezone
9from io import BytesIO
10from pathlib import Path
11from typing import IO, TYPE_CHECKING, Any, BinaryIO, ClassVar, Literal, overload
12from xml.etree.ElementTree import Element, ElementTree, SubElement
13from xml.etree.ElementTree import indent as indent_xml
14
15from icalendar import config
16from icalendar.attr import (
17 CONCEPTS_TYPE_SETTER,
18 LINKS_TYPE_SETTER,
19 RELATED_TO_TYPE_SETTER,
20 comments_property,
21 concepts_property,
22 links_property,
23 refids_property,
24 related_to_property,
25 single_utc_property,
26 uid_property,
27)
28from icalendar.cal.component_factory import ComponentFactory
29from icalendar.caselessdict import CaselessDict
30from icalendar.error import InvalidCalendar, JCalParsingError
31from icalendar.parser import (
32 Contentline,
33 Contentlines,
34 Parameters,
35 q_join,
36 q_split,
37)
38from icalendar.parser.ical.component import ComponentIcalParser
39from icalendar.parser_tools import DEFAULT_ENCODING
40from icalendar.prop import VPROPERTY, TypesFactory, vDDDLists, vText, vUnknown
41from icalendar.timezone import tzp
42from icalendar.tools import is_date
43
44if TYPE_CHECKING:
45 from collections.abc import Iterable
46
47 from icalendar.compatibility import Self
48
49_marker = []
50
51
52@dataclass
53class _ComponentEqFrame:
54 """A pending component-equality comparison on the iterative stack.
55
56 See ``Component.__eq__`` for how the fields are used.
57 """
58
59 #: the two components being compared
60 a: Component
61 b: Component
62 #: ``b``'s subcomponents not yet matched against one of ``a``'s. ``None``
63 #: until ``a`` and ``b``'s own properties have been compared and found equal.
64 unmatched: list | None = None
65 #: index of the ``a`` subcomponent we are currently trying to match
66 a_index: int = 0
67 #: index of the unmatched ``b`` subcomponent we are currently testing
68 candidate_index: int = 0
69
70
71class Component(CaselessDict):
72 """Base class for calendar components.
73
74 Component is the base object for calendar, Event and the other
75 components defined in :rfc:`5545`. Normally you will not use this class
76 directly, but rather one of the subclasses.
77 """
78
79 name: ClassVar[str | None] = None
80 """The name of the component.
81
82 This is defined in each component class.
83
84 Example:
85
86 .. code-block:: pycon
87
88 >>> from icalendar import Calendar
89 >>> cal = Calendar.new()
90 >>> cal.name
91 'VCALENDAR'
92
93 """
94
95 required: ClassVar[tuple[()]] = ()
96 """These properties are required."""
97
98 singletons: ClassVar[tuple[()]] = ()
99 """These properties must appear only once."""
100
101 multiple: ClassVar[tuple[()]] = ()
102 """These properties may occur more than once."""
103
104 exclusive: ClassVar[tuple[()]] = ()
105 """These properties are mutually exclusive."""
106
107 inclusive: ClassVar[(tuple[str] | tuple[tuple[str, str]])] = ()
108 """These properties are inclusive.
109
110 In other words, if the first property in the tuple occurs, then the
111 second one must also occur.
112
113 Example:
114
115 .. code-block:: python
116
117 ('duration', 'repeat')
118 """
119
120 ignore_exceptions: ClassVar[bool] = False
121 """Whether or not to ignore exceptions when parsing.
122
123 If ``True``, and this component can't be parsed, then it will silently
124 ignore it, rather than let the exception propagate upwards.
125 """
126
127 types_factory: ClassVar[TypesFactory] = TypesFactory.instance()
128 """The factory class to parse and wrap properties."""
129
130 _components_factory: ClassVar[ComponentFactory | None] = None
131
132 subcomponents: list[Component]
133 """All subcomponents of this component."""
134
135 @classmethod
136 def _get_component_factory(cls) -> ComponentFactory:
137 """Get the component factory."""
138 if cls._components_factory is None:
139 cls._components_factory = ComponentFactory()
140 return cls._components_factory
141
142 @classmethod
143 def get_component_class(cls, name: str) -> type[Component]:
144 """Return a component with this name.
145
146 Parameters:
147 name: Name of the component, i.e. ``VCALENDAR``
148 """
149 return cls._get_component_factory().get_component_class(name)
150
151 @classmethod
152 def register(cls, component_class: type[Component]) -> None:
153 """Register a custom component class.
154
155 Parameters:
156 component_class: Component subclass to register.
157 Must have a ``name`` attribute.
158
159 Raises:
160 ValueError: If ``component_class`` has no ``name`` attribute.
161 ValueError: If a component with this name is already registered.
162
163 Examples:
164 Create a custom icalendar component with the name ``X-EXAMPLE``:
165
166 .. code-block:: pycon
167
168 >>> from icalendar import Component
169 >>> class XExample(Component):
170 ... name = "X-EXAMPLE"
171 ... def custom_method(self):
172 ... return "custom"
173 >>> Component.register(XExample)
174 """
175 if not hasattr(component_class, "name") or component_class.name is None:
176 raise ValueError(f"{component_class} must have a 'name' attribute")
177
178 # Check if already registered
179 component_factory = cls._get_component_factory()
180 existing = component_factory.get(component_class.name)
181 if existing is not None and existing is not component_class:
182 raise ValueError(
183 f"Component '{component_class.name}' is already registered"
184 f" as {existing}"
185 )
186
187 component_factory.add_component_class(component_class)
188
189 @staticmethod
190 def _infer_value_type(
191 value: date | datetime | timedelta | time | tuple | list,
192 ) -> str | None:
193 """Infer the ``VALUE`` parameter from a Python type.
194
195 Parameters:
196 value: Python native type, one of :class:`datetime.date`, :class:`datetime.datetime`,
197 :class:`datetime.timedelta`, :class:`datetime.time`, :class:`tuple`,
198 or :class:`list`.
199
200 Returns:
201 str or None: The ``VALUE`` parameter string, for example, "DATE",
202 "TIME", or other string, or ``None``
203 if no specific ``VALUE`` is needed.
204 """
205 if isinstance(value, list):
206 if not value:
207 return None
208 # Check if ALL items are date (but not datetime)
209 if all(is_date(item) for item in value):
210 return "DATE"
211 # Check if ALL items are time
212 if all(isinstance(item, time) for item in value):
213 return "TIME"
214 # Mixed types or other types - don't infer
215 return None
216 if is_date(value):
217 return "DATE"
218 if isinstance(value, time):
219 return "TIME"
220 # Don't infer PERIOD - it's too risky and vPeriod already handles it
221 return None
222
223 def __init__(self, *args: Any, **kwargs: Any) -> None:
224 """Set keys to upper for initial dict."""
225 super().__init__(*args, **kwargs)
226 # set parameters here for properties that use non-default values
227 self.subcomponents: list[Component] = [] # Components can be nested.
228 self.errors: list[
229 tuple[str | None, str]
230 ] = [] # If we ignored exception(s) while
231 # parsing a property, contains error strings
232
233 def __bool__(self) -> bool:
234 """Returns True, CaselessDict would return False if it had no items."""
235 return True
236
237 def __getitem__(self, key) -> VPROPERTY:
238 """Get property value from the component dictionary."""
239 return super().__getitem__(key)
240
241 def get(self, key, default=None) -> Any:
242 """Get property value with default."""
243 try:
244 return self[key]
245 except KeyError:
246 return default
247
248 def is_empty(self) -> bool:
249 """Returns True if Component has no items or subcomponents, else False."""
250 return bool(not list(self.values()) + self.subcomponents)
251
252 #############################
253 # handling of property values
254
255 @classmethod
256 def _encode(cls, name, value, parameters=None, encode=1):
257 """Encode values to icalendar property values.
258
259 :param name: Name of the property.
260 :type name: string
261
262 :param value: Value of the property. Either of a basic Python type of
263 any of the icalendar's own property types.
264 :type value: Python native type or icalendar property type.
265
266 :param parameters: Property parameter dictionary for the value. Only
267 available, if encode is set to True.
268 :type parameters: Dictionary
269
270 :param encode: True, if the value should be encoded to one of
271 icalendar's own property types (Fallback is "vText")
272 or False, if not.
273 :type encode: Boolean
274
275 :returns: icalendar property value
276 """
277 if not encode:
278 return value
279 if isinstance(value, cls.types_factory.all_types):
280 # Don't encode already encoded values.
281 obj = value
282 else:
283 # Extract VALUE parameter if present, or infer it from the Python type
284 value_param = None
285 if parameters and "VALUE" in parameters:
286 value_param = parameters["VALUE"]
287 elif not isinstance(value, cls.types_factory.all_types):
288 inferred = cls._infer_value_type(value)
289 if inferred:
290 value_param = inferred
291 # Auto-set the VALUE parameter
292 if parameters is None:
293 parameters = {}
294 if "VALUE" not in parameters:
295 parameters["VALUE"] = inferred
296
297 klass = cls.types_factory.for_property(name, value_param)
298 obj = klass(value)
299 if parameters:
300 if not hasattr(obj, "params"):
301 obj.params = Parameters()
302 for key, item in parameters.items():
303 if item is None:
304 if key in obj.params:
305 del obj.params[key]
306 else:
307 obj.params[key] = item
308 return obj
309
310 def add(
311 self,
312 name: str,
313 value,
314 parameters: dict[str, str] | Parameters = None,
315 encode: bool = True,
316 ) -> None:
317 """Add a property to this component.
318
319 If the property already exists, the new value is appended so the
320 property carries a list of values rather than replacing the previous
321 one. When ``name`` is ``DTSTAMP``, ``CREATED``, or ``LAST-MODIFIED``
322 and ``value`` is a ``datetime``, the value is converted to UTC as the
323 RFC requires.
324
325 Parameters:
326 name: Name of the property.
327 value:
328 Value of the property. Either a basic Python type or any of
329 icalendar's own property types.
330 parameters:
331 Property parameter dictionary for the value. Only consulted
332 when ``encode`` is ``True``.
333 encode:
334 ``True`` if the value should be encoded to one of icalendar's
335 own property types (fallback is ``vText``); ``False`` to
336 store the value as-is.
337
338 Returns:
339 ``None``
340
341 Example:
342
343 >>> from icalendar import Event
344 >>> event = Event()
345 >>> event.add("summary", "Team sync")
346 >>> event["summary"]
347 vText(b'Team sync')
348
349 """
350 if isinstance(value, datetime) and name.lower() in (
351 "dtstamp",
352 "created",
353 "last-modified",
354 ):
355 # RFC expects UTC for those... force value conversion.
356 value = tzp.localize_utc(value)
357
358 # encode value
359 if (
360 encode
361 and isinstance(value, list)
362 and name.lower() not in ["rdate", "exdate", "categories"]
363 ):
364 # Individually convert each value to an ical type except rdate and
365 # exdate, where lists of dates might be passed to vDDDLists.
366 value = [self._encode(name, v, parameters, encode) for v in value]
367 else:
368 value = self._encode(name, value, parameters, encode)
369
370 # set value
371 if name in self:
372 # If property already exists, append it.
373 oldval = self[name]
374 if isinstance(oldval, list):
375 if isinstance(value, list):
376 value = oldval + value
377 else:
378 oldval.append(value)
379 value = oldval
380 else:
381 value = [oldval, value]
382 self[name] = value
383
384 def _decode(self, name: str, value: VPROPERTY):
385 """Internal for decoding property values."""
386
387 # TODO: Currently the decoded method calls the icalendar.prop instances
388 # from_ical. We probably want to decode properties into Python native
389 # types here. But when parsing from an ical string with from_ical, we
390 # want to encode the string into a real icalendar.prop property.
391 if hasattr(value, "ical_value"):
392 return value.ical_value
393 if isinstance(value, vDDDLists):
394 # TODO: Workaround unfinished decoding
395 return value
396 decoded = self.types_factory.from_ical(name, value)
397 # TODO: remove when proper decoded is implemented in every prop.* class
398 # Workaround to decode vText properly. vUnknown is not a vText
399 # subclass (RFC 7265), but its value is decoded the same way here.
400 if isinstance(decoded, (vText, vUnknown)):
401 decoded = decoded.encode(DEFAULT_ENCODING)
402 return decoded
403
404 def decoded(self, name: str, default: Any = _marker) -> Any:
405 """Returns decoded value of property.
406
407 A component maps keys to icalendar property value types.
408 This function returns values compatible to native Python types.
409 """
410 if name in self:
411 value = self[name]
412 if isinstance(value, list):
413 return [self._decode(name, v) for v in value]
414 return self._decode(name, value)
415 if default is _marker:
416 raise KeyError(name)
417 return default
418
419 ########################################################################
420 # Inline values. A few properties have multiple values inlined in in one
421 # property line. These methods are used for splitting and joining these.
422
423 def get_inline(self, name, decode=1):
424 """Returns a list of values (split on comma)."""
425 vals = [v.strip('" ') for v in q_split(self[name])]
426 if decode:
427 return [self._decode(name, val) for val in vals]
428 return vals
429
430 def set_inline(self, name, values, encode=1):
431 """Converts a list of values into comma separated string and sets value
432 to that.
433 """
434 if encode:
435 values = [self._encode(name, value, encode=1) for value in values]
436 self[name] = self.types_factory["inline"](q_join(values))
437
438 #########################
439 # Handling of components
440
441 def add_component(self, component: Component) -> None:
442 """Add a subcomponent to this component."""
443 self.subcomponents.append(component)
444
445 def _walk(
446 self, name: str | None, select: callable[[Component], bool]
447 ) -> list[Component]:
448 """Walk to given component."""
449 result = []
450 stack = [self]
451 while stack:
452 component = stack.pop()
453 if (name is None or component.name == name) and select(component):
454 result.append(component)
455 stack.extend(reversed(component.subcomponents))
456 return result
457
458 def walk(
459 self,
460 name: str | None = None,
461 select: callable[[Component], bool] = lambda _: True,
462 ) -> list[Component]:
463 """Recursively traverses component and subcomponents. Returns sequence
464 of same. If name is passed, only components with name will be returned.
465
466 :param name: The name of the component or None such as ``VEVENT``.
467 :param select: A function that takes the component as first argument
468 and returns True/False.
469 :returns: A list of components that match.
470 :rtype: list[Component]
471 """
472 if name is not None:
473 name = name.upper()
474 return self._walk(name, select)
475
476 def with_uid(self, uid: str) -> list[Component]:
477 """Return a list of components with the given UID.
478
479 Parameters:
480 uid: The UID of the component.
481
482 Returns:
483 list[Component]: List of components with the given UID.
484 """
485 return self.walk(select=lambda c: c.uid == uid)
486
487 def _validate_name(self):
488 """Make sure the component has a name.
489
490 Raises:
491 ValueError: If this component does not have a name.
492 """
493 if self.name is None:
494 raise ValueError("This component needs a name for serialization.", self)
495
496 #####################
497 # Generation
498
499 def property_items(
500 self,
501 recursive: bool = True,
502 sorted: bool = True,
503 ) -> list[tuple[str, object]]:
504 """Returns properties in this component and subcomponents as a list.
505
506 The list contains ``(name, value)`` tuples.
507 """
508 # Iterative implementation to avoid RecursionError
509 result = []
510 v_text = self.types_factory["text"]
511 # Stack stores (component, state)
512 # state: True means we are processing the END of the component
513 # state: False means we are processing the BEGIN and properties of the component
514 stack = [(self, False)]
515 while stack:
516 comp, is_end = stack.pop()
517 comp._validate_name()
518 if is_end:
519 result.append(("END", v_text(comp.name).to_ical()))
520 else:
521 result.append(("BEGIN", v_text(comp.name).to_ical()))
522 property_names = comp.sorted_keys() if sorted else comp.keys()
523
524 for name in property_names:
525 values = comp[name]
526 if isinstance(values, list):
527 # normally one property is one line
528 for value in values:
529 result.append((name, value))
530 else:
531 result.append((name, values))
532
533 # Push the END marker for this component
534 stack.append((comp, True))
535 # Push subcomponents if recursion is enabled
536 if recursive:
537 # Push in reverse order to maintain original order in result
538 for subcomponent in reversed(comp.subcomponents):
539 stack.append((subcomponent, False))
540
541 return result
542
543 @overload
544 @classmethod
545 def from_ical(
546 cls, st: str | bytes | Path, multiple: Literal[False] = False
547 ) -> Component: ...
548
549 @overload
550 @classmethod
551 def from_ical(
552 cls, st: str | bytes | Path, multiple: Literal[True]
553 ) -> list[Component]: ...
554
555 @classmethod
556 def _get_ical_parser(cls, st: str | bytes) -> ComponentIcalParser:
557 """Get the iCal parser for the given input string."""
558 return ComponentIcalParser(st, cls._get_component_factory(), cls.types_factory)
559
560 @classmethod
561 def from_ical(
562 cls, st: str | bytes | Path, multiple: bool = False
563 ) -> Component | list[Component]:
564 """Parse iCalendar data into component instances.
565
566 Handles standard and custom components (``X-*``, IANA-registered).
567
568 Parameters:
569 st: iCalendar data as bytes or string, or a path to an iCalendar file as
570 :class:`pathlib.Path`.
571 multiple: If ``True``, returns list. If ``False``, returns single component.
572
573 Returns:
574 Component or list of components
575
576 See Also:
577 :doc:`/how-to/custom-components` for examples of parsing custom components
578 """
579 if isinstance(st, Path):
580 st = st.read_bytes()
581 parser = cls._get_ical_parser(st)
582 components = parser.parse()
583 if multiple:
584 return components
585 if len(components) > 1:
586 raise ValueError(
587 cls._format_error(
588 "Found multiple components where only one is allowed", st
589 )
590 )
591 if len(components) < 1:
592 raise ValueError(
593 cls._format_error(
594 "Found no components where exactly one is required", st
595 )
596 )
597 return components[0]
598
599 @staticmethod
600 def _format_error(error_description, bad_input, elipsis="[...]"):
601 # there's three character more in the error, ie. ' ' x2 and a ':'
602 max_error_length = 100 - 3
603 if len(error_description) + len(bad_input) + len(elipsis) > max_error_length:
604 truncate_to = max_error_length - len(error_description) - len(elipsis)
605 return f"{error_description}: {bad_input[:truncate_to]} {elipsis}"
606 return f"{error_description}: {bad_input}"
607
608 def content_line(self, name, value, sorted: bool = True):
609 """Returns property as content line."""
610 params = getattr(value, "params", Parameters())
611 return Contentline.from_parts(name, params, value, sorted=sorted)
612
613 def content_lines(self, sorted: bool = True):
614 """Converts the Component and subcomponents into content lines."""
615 contentlines = Contentlines()
616 for name, value in self.property_items(sorted=sorted):
617 cl = self.content_line(name, value, sorted=sorted)
618 contentlines.append(cl)
619 contentlines.append("") # remember the empty string in the end
620 return contentlines
621
622 def to_ical(self, sorted: bool = True):
623 """
624 :param sorted: Whether parameters and properties should be
625 lexicographically sorted.
626 """
627
628 content_lines = self.content_lines(sorted=sorted)
629 return content_lines.to_ical()
630
631 def __repr__(self) -> str:
632 """String representation of class with all of its subcomponents.
633
634 Implemented iteratively rather than recursively so that calendars
635 with deeply nested subcomponents do not raise ``RecursionError``.
636 A pathological ``.ics`` payload of only ~13 KB can otherwise nest
637 ``BEGIN:VEVENT`` ~500 levels and crash any caller that performs
638 ``repr()``/``str()``/``f"{cal}"`` on the parsed calendar
639 (e.g. logging, error reporting, debug pages).
640 """
641 # Stack-based traversal. Each frame is one of:
642 # ("open", component) -> emit "Name({props}" and schedule children
643 # ("close",) -> emit ")"
644 # ("comma",) -> emit ", "
645 out: list[str] = []
646 stack: list[tuple] = [("open", self)]
647 while stack:
648 frame = stack.pop()
649 kind = frame[0]
650 if kind == "comma":
651 out.append(", ")
652 elif kind == "close":
653 out.append(")")
654 else: # "open"
655 node = frame[1]
656 if isinstance(node, Component):
657 out.append(f"{node.name or type(node).__name__}({dict(node)}")
658 subs = node.subcomponents
659 if subs:
660 # Defer ")" then push children in reverse so that
661 # popping yields original order, with ", " separators
662 # (the first popped comma serves as the separator
663 # between the component's dict and its first child).
664 stack.append(("close",))
665 for sub in reversed(subs):
666 stack.append(("open", sub))
667 stack.append(("comma",))
668 else:
669 out.append(")")
670 else:
671 # Should not normally occur (subcomponents are Components),
672 # but be safe and fall back to non-recursive str().
673 out.append(str(node))
674 return "".join(out)
675
676 def __eq__(self, other: Component) -> bool:
677 if not isinstance(other, Component):
678 return NotImplemented
679
680 # Two components are equal when their own properties are equal and their
681 # subcomponents are equal as a multiset: order does not matter, and each
682 # nested pair is compared the same way recursively. Subcomponents are
683 # neither sortable nor hashable, so we can't use a set; we have to match
684 # each one by searching. Done recursively that search is exponential for
685 # deeply nested components (GHSA-cv84-9p8j-fj68), so we walk an explicit
686 # stack instead of recursing.
687 #
688 # Each frame holds the pair being compared plus b's subcomponents
689 # still unmatched. child_result carries the outcome of the comparison
690 # that just finished back up to its parent frame: a successful child
691 # match removes that subcomponent from unmatched and advances to the
692 # next a subcomponent, while a failure tries the next candidate.
693 # Exhausting a's subcomponents means every one found a partner ->
694 # equal; running out of candidates for some subcomponent -> not equal.
695 # (Greedy matching is sufficient because equality is transitive, so equal
696 # candidates are interchangeable.)
697 stack = [_ComponentEqFrame(self, other)]
698 child_result = None
699 while stack:
700 frame = stack[-1]
701 if frame.unmatched is None:
702 if len(frame.a.subcomponents) != len(frame.b.subcomponents) or not (
703 CaselessDict.__eq__(frame.a, frame.b)
704 ):
705 stack.pop()
706 child_result = False
707 continue
708 frame.unmatched = list(frame.b.subcomponents)
709 elif child_result is not None:
710 if child_result:
711 del frame.unmatched[frame.candidate_index]
712 frame.a_index += 1
713 frame.candidate_index = 0
714 else:
715 frame.candidate_index += 1
716 child_result = None
717 if frame.a_index >= len(frame.a.subcomponents):
718 stack.pop()
719 child_result = True
720 elif frame.candidate_index >= len(frame.unmatched):
721 stack.pop()
722 child_result = False
723 else:
724 stack.append(
725 _ComponentEqFrame(
726 frame.a.subcomponents[frame.a_index],
727 frame.unmatched[frame.candidate_index],
728 )
729 )
730 return child_result
731
732 DTSTAMP = single_utc_property(
733 "DTSTAMP",
734 """The UTC datetime stamp recording when this component instance was created or last revised.
735
736 This property is defined in :rfc:`5545#section-3.8.7.2`. It's required
737 in ``VEVENT``, ``VTODO``, ``VJOURNAL``, and ``VFREEBUSY`` components.
738
739 When the calendar object carries a ``METHOD`` property, such as for
740 scheduling, this value is the creation time of *this particular revision*.
741 Without a ``METHOD`` property, it's equivalent to :attr:`LAST_MODIFIED`.
742
743 The value is always in UTC. It's also accessible as :attr:`stamp`.
744
745 Example:
746 .. code-block:: pycon
747
748 >>> from datetime import timezone, datetime
749 >>> from icalendar import Event
750 >>> event = Event()
751 >>> event.DTSTAMP = datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
752 >>> event.DTSTAMP
753 datetime.datetime(2024, 6, 1, 12, 0, tzinfo=ZoneInfo(key='UTC'))
754
755 See also:
756 :attr:`CREATED`, :attr:`LAST_MODIFIED`,
757 :attr:`created`, :attr:`stamp`, :attr:`last_modified`
758 """,
759 )
760
761 @property
762 def stamp(self) -> datetime | None:
763 """Datetime stamp of this component, as a :class:`~datetime.datetime` in UTC.
764
765 This is the lowercase property counterpart to, and accessor for, :attr:`DTSTAMP`.
766 """
767 return self.DTSTAMP
768
769 @stamp.setter
770 def stamp(self, value: datetime) -> None:
771 self.DTSTAMP = value
772
773 @stamp.deleter
774 def stamp(self) -> None:
775 del self.DTSTAMP
776
777 LAST_MODIFIED = single_utc_property(
778 "LAST-MODIFIED",
779 """The UTC datetime when this component's information was last revised, per :rfc:`5545#section-3.8.7.3`.
780
781 It's analogous to a file's modification timestamp. This property is optional.
782 When it's absent, :attr:`last_modified` falls back to :attr:`DTSTAMP`.
783
784 This property is applicable to ``VEVENT``, ``VTODO``, ``VJOURNAL``, and ``VTIMEZONE``
785 components. The value is always in UTC.
786
787 Example:
788 .. code-block:: pycon
789
790 >>> from datetime import timezone, datetime
791 >>> from icalendar import Event
792 >>> event = Event()
793 >>> event.LAST_MODIFIED = datetime(2024, 6, 1, 9, 0, 0, tzinfo=timezone.utc)
794 >>> event.LAST_MODIFIED
795 datetime.datetime(2024, 6, 1, 9, 0, tzinfo=ZoneInfo(key='UTC'))
796
797 See also:
798 :attr:`CREATED`, :attr:`DTSTAMP`,
799 :attr:`created`, :attr:`stamp`, :attr:`last_modified`
800 """,
801 )
802
803 @property
804 def last_modified(self) -> datetime:
805 """Datetime when the information associated with the component was last revised.
806
807 Since :attr:`LAST_MODIFIED` is an optional property,
808 this returns :attr:`DTSTAMP` if :attr:`LAST_MODIFIED` is not set.
809 """
810 return self.LAST_MODIFIED or self.DTSTAMP
811
812 @last_modified.setter
813 def last_modified(self, value):
814 self.LAST_MODIFIED = value
815
816 @last_modified.deleter
817 def last_modified(self):
818 del self.LAST_MODIFIED
819
820 @property
821 def created(self) -> datetime:
822 """Datetime when the information associated with the component was created.
823
824 Since :attr:`CREATED` is an optional property,
825 this returns :attr:`DTSTAMP` if :attr:`CREATED` is not set.
826 """
827 return self.CREATED or self.DTSTAMP
828
829 @created.setter
830 def created(self, value):
831 self.CREATED = value
832
833 @created.deleter
834 def created(self):
835 del self.CREATED
836
837 def is_thunderbird(self) -> bool:
838 """Whether this component has attributes that indicate that Mozilla Thunderbird created it."""
839 return any(attr.startswith("X-MOZ-") for attr in self.keys())
840
841 @staticmethod
842 def _utc_now() -> datetime:
843 """Return now as UTC value."""
844 return datetime.now(timezone.utc)
845
846 uid = uid_property
847 comments = comments_property
848 links = links_property
849 related_to = related_to_property
850 concepts = concepts_property
851 refids = refids_property
852
853 CREATED = single_utc_property(
854 "CREATED",
855 """The UTC datetime when this calendar component was first created, per :rfc:`5545#section-3.8.7.1`.
856
857 This property records when the calendar user agent originally stored the component.
858 This property is optional. When it's absent, :attr:`created` falls back to
859 :attr:`DTSTAMP`.
860
861 This property is applicable to ``VEVENT``, ``VTODO``, and ``VJOURNAL`` components.
862 The value is always in UTC.
863
864 Example:
865 .. code-block:: pycon
866
867 >>> from datetime import timezone, datetime
868 >>> from icalendar import Event
869 >>> event = Event()
870 >>> event.CREATED = datetime(2024, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
871 >>> event.CREATED
872 datetime.datetime(2024, 1, 1, 8, 0, tzinfo=ZoneInfo(key='UTC'))
873
874 See also:
875 :attr:`DTSTAMP`, :attr:`LAST_MODIFIED`,
876 :attr:`created`, :attr:`stamp`, :attr:`last_modified`
877 """,
878 )
879
880 _validate_new = True
881
882 @staticmethod
883 def _validate_start_and_end(start, end):
884 """This validates start and end.
885
886 Raises:
887 ~error.InvalidCalendar: If the information is not valid
888 """
889 if start is None or end is None:
890 return
891 if start > end:
892 raise InvalidCalendar("end must be after start")
893
894 @classmethod
895 def new(
896 cls,
897 created: date | None = None,
898 comments: list[str] | str | None = None,
899 concepts: CONCEPTS_TYPE_SETTER = None,
900 last_modified: date | None = None,
901 links: LINKS_TYPE_SETTER = None,
902 refids: list[str] | str | None = None,
903 related_to: RELATED_TO_TYPE_SETTER = None,
904 stamp: date | None = None,
905 subcomponents: Iterable[Component] | None = None,
906 ) -> Self:
907 """Create a new component.
908
909 Parameters:
910 comments: The :attr:`comments` of the component.
911 concepts: The :attr:`concepts` of the component.
912 created: The :attr:`created` of the component.
913 last_modified: The :attr:`last_modified` of the component.
914 links: The :attr:`links` of the component.
915 related_to: The :attr:`related_to` of the component.
916 stamp: The :attr:`DTSTAMP` of the component.
917 subcomponents: The subcomponents of the component.
918
919 Raises:
920 ~error.InvalidCalendar: If the content is not valid
921 according to :rfc:`5545`.
922
923 .. warning:: As time progresses, we will be stricter with the
924 validation.
925 """
926 component = cls()
927 component.DTSTAMP = stamp
928 component.created = created
929 component.last_modified = last_modified
930 component.comments = comments
931 component.links = links
932 component.related_to = related_to
933 component.concepts = concepts
934 component.refids = refids
935 if subcomponents is not None:
936 component.subcomponents = (
937 subcomponents
938 if isinstance(subcomponents, list)
939 else list(subcomponents)
940 )
941 return component
942
943 def to_jcal(self) -> list:
944 """Convert this component to a jCal object.
945
946 Returns:
947 jCal object
948
949 Raises:
950 ValueError: If a component does not have a name.
951
952 See also :attr:`to_json`.
953
954 In this example, we create a simple VEVENT component and convert it to jCal:
955
956 .. code-block:: pycon
957
958 >>> from icalendar import Event
959 >>> from datetime import date
960 >>> from pprint import pprint
961 >>> event = Event.new(summary="My Event", start=date(2025, 11, 22))
962 >>> pprint(event.to_jcal())
963 ['vevent',
964 [['summary', {}, 'text', 'My Event'],
965 ['dtstart', {}, 'date', '2025-11-22'],
966 ['dtstamp', {}, 'date-time', '2025-05-17T08:06:12Z'],
967 ['uid', {}, 'text', 'd755cef5-2311-46ed-a0e1-6733c9e15c63']],
968 []]
969 """
970
971 # Iterative tree walk to avoid RecursionError on deeply nested
972 # components, mirroring the iterative iCal parser/serializer (GH #1370).
973 def make_node(comp: Component) -> list:
974 properties = [
975 item.to_jcal(key.lower())
976 for key, value in comp.sorted_items()
977 for item in (value if isinstance(value, list) else [value])
978 ]
979 comp._validate_name()
980 return [comp.name.lower(), properties, []]
981
982 root_node = make_node(self)
983 # stack of (component, jCal node) pairs still to expand
984 stack: list[tuple[Component, list]] = [(self, root_node)]
985 while stack:
986 comp, node = stack.pop()
987 children = node[2]
988 for subcomponent in comp.subcomponents:
989 child_node = make_node(subcomponent)
990 children.append(child_node)
991 stack.append((subcomponent, child_node))
992 return root_node
993
994 def to_json(self, indent: int | str | None = None) -> str:
995 """Return this component as a jCal JSON string.
996
997 Parameters:
998 indent: If a non-negative integer or string is provided,
999 then JSON will be pretty-printed with that indent level.
1000
1001 Returns:
1002 JSON string
1003
1004 Raises:
1005 ValueError: If a component does not have a name.
1006
1007 See also :attr:`to_jcal`.
1008 """
1009 return json.dumps(
1010 self.to_jcal(),
1011 indent=indent,
1012 sort_keys=True,
1013 separators=(",", ":") if indent is None else None,
1014 )
1015
1016 @classmethod
1017 def from_jcal(cls, jcal: str | list) -> Component:
1018 """Create a component from a jCal list.
1019
1020 Parameters:
1021 jcal: jCal list or JSON string according to :rfc:`7265`.
1022
1023 Raises:
1024 ~error.JCalParsingError: If the jCal provided is invalid.
1025 ~json.JSONDecodeError: If the provided string is not valid JSON.
1026
1027 This reverses :func:`to_json` and :func:`to_jcal`.
1028
1029 The following code parses an example from :rfc:`7265`:
1030
1031 .. code-block:: pycon
1032
1033 >>> from icalendar import Component
1034 >>> jcal = ["vcalendar",
1035 ... [
1036 ... ["calscale", {}, "text", "GREGORIAN"],
1037 ... ["prodid", {}, "text", "-//Example Inc.//Example Calendar//EN"],
1038 ... ["version", {}, "text", "2.0"]
1039 ... ],
1040 ... [
1041 ... ["vevent",
1042 ... [
1043 ... ["dtstamp", {}, "date-time", "2008-02-05T19:12:24Z"],
1044 ... ["dtstart", {}, "date", "2008-10-06"],
1045 ... ["summary", {}, "text", "Planning meeting"],
1046 ... ["uid", {}, "text", "4088E990AD89CB3DBB484909"]
1047 ... ],
1048 ... []
1049 ... ]
1050 ... ]
1051 ... ]
1052 >>> calendar = Component.from_jcal(jcal)
1053 >>> print(calendar.name)
1054 VCALENDAR
1055 >>> print(calendar.prodid)
1056 -//Example Inc.//Example Calendar//EN
1057 >>> event = calendar.events[0]
1058 >>> print(event.summary)
1059 Planning meeting
1060
1061 """
1062 if isinstance(jcal, str):
1063 jcal = json.loads(jcal)
1064 # Iterative tree build to avoid RecursionError on deeply nested jCal,
1065 # mirroring the iterative iCal parser (GH #1370). ``_node_from_jcal``
1066 # parses a single component (without its subcomponents); the stack walks
1067 # the subcomponent tree, accumulating the jCal error path ([2, i] per
1068 # nesting level) so error messages match the recursive implementation.
1069 root, root_subcomponents = _node_from_jcal(jcal, cls)
1070 stack: list[tuple[Component, list, list]] = [(root, root_subcomponents, [])]
1071 while stack:
1072 parent, subcomponents, prefix = stack.pop()
1073 for i, subcomponent in enumerate(subcomponents):
1074 child_prefix = [*prefix, 2, i]
1075 # Prepend the full nesting path so errors match the recursive
1076 # implementation. This also preserves the error value and
1077 # traceback, like the nested context managers did before.
1078 with JCalParsingError.reraise_with_path_added(*child_prefix):
1079 child, child_subcomponents = _node_from_jcal(
1080 subcomponent, type(parent)
1081 )
1082 parent.subcomponents.append(child)
1083 stack.append((child, child_subcomponents, child_prefix))
1084 return root
1085
1086 def copy(self, recursive: bool = False) -> Self:
1087 """Copy the component.
1088
1089 Parameters:
1090 recursive:
1091 If ``True``, this creates copies of the component, its subcomponents,
1092 and all its properties.
1093 If ``False``, this only creates a shallow copy of the component.
1094
1095 Returns:
1096 A copy of the component.
1097
1098 Examples:
1099
1100 Create a shallow copy of a component:
1101
1102 .. code-block:: pycon
1103
1104 >>> from icalendar import Event
1105 >>> event = Event.new(description="Event to be copied")
1106 >>> event_copy = event.copy()
1107 >>> str(event_copy.description)
1108 'Event to be copied'
1109
1110 Shallow copies lose their subcomponents:
1111
1112 .. code-block:: pycon
1113
1114 >>> from icalendar import Calendar
1115 >>> calendar = Calendar.example()
1116 >>> len(calendar.subcomponents)
1117 3
1118 >>> calendar_copy = calendar.copy()
1119 >>> len(calendar_copy.subcomponents)
1120 0
1121
1122 A recursive copy also copies all the subcomponents:
1123
1124 .. code-block:: pycon
1125
1126 >>> full_calendar_copy = calendar.copy(recursive=True)
1127 >>> len(full_calendar_copy.subcomponents)
1128 3
1129 >>> full_calendar_copy.events[0] == calendar.events[0]
1130 True
1131 >>> full_calendar_copy.events[0] is calendar.events[0]
1132 False
1133
1134 """
1135 if recursive:
1136 return deepcopy(self)
1137 return super().copy()
1138
1139 def is_lazy(self) -> bool:
1140 """This component is fully parsed."""
1141 return False
1142
1143 def parse(self) -> Self:
1144 """Return the fully parsed component.
1145
1146 For non-lazy components, this returns self.
1147 For lazy components, this parses the component and returns the result.
1148 """
1149 return self
1150
1151 @overload
1152 def to_xcal(
1153 self,
1154 destination: Element | IO[bytes],
1155 ) -> None: ...
1156
1157 @overload
1158 def to_xcal(
1159 self, destination: Element | IO[bytes], *, indent: int | str | None = None
1160 ) -> None: ...
1161
1162 @overload
1163 def to_xcal(self, *, indent: int | str | None = None) -> bytes: ...
1164
1165 def to_xcal(
1166 self,
1167 destination: Element | IO[bytes] | None = None,
1168 *,
1169 indent: int | str | None = None,
1170 ) -> bytes | None:
1171 """The xCal representation of this component according to :rfc:`6321`.
1172
1173 Parameters:
1174 destination: The iCalendar stream or a file.
1175 indent: The indentation for pretty printing the XML.
1176
1177 Returns:
1178 - ``None`` if an argument is passed.
1179 - :class:`bytes` if called with no arguments.
1180
1181 Example:
1182 This example shows how to convert a new calendar to XML:
1183
1184 .. code-block:: pycon
1185
1186 >>> from icalendar import Calendar
1187 >>> cal = Calendar.new()
1188 >>> print(cal.to_xcal(indent=2).decode())
1189 <?xml version="1.0" encoding="UTF-8"?>
1190 <icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0">
1191 <vcalendar>
1192 <properties>
1193 <version>
1194 <text>2.0</text>
1195 </version>
1196 <prodid>
1197 <text>-//collective//icalendar//7.0.0//EN</text>
1198 </prodid>
1199 <uid>
1200 <text>d755cef5-2311-46ed-a0e1-6733c9e15c63</text>
1201 </uid>
1202 </properties>
1203 </vcalendar>
1204 </icalendar>
1205 """
1206 from io import BytesIO
1207
1208 input_is_element = isinstance(destination, Element)
1209 if input_is_element:
1210 stream = destination
1211 else:
1212 stream = Element("icalendar", xmlns="urn:ietf:params:xml:ns:icalendar-2.0")
1213 if destination is not None and not hasattr(destination, "write"):
1214 raise TypeError(
1215 "Expected an XML Element or a file-like "
1216 f"object for destination, got {destination!r}"
1217 )
1218 self.to_xcal_element(stream)
1219 if indent is not None:
1220 if isinstance(indent, int):
1221 indent = " " * indent
1222 elif not isinstance(indent, str):
1223 raise TypeError(
1224 f"Expected an integer or string for indent, got {type(indent)}"
1225 )
1226 if input_is_element:
1227 return None
1228 return_bytes = destination is None
1229 file = BytesIO() if return_bytes else destination
1230 file.write(b'<?xml version="1.0" encoding="UTF-8"?>')
1231 if indent is not None:
1232 file.write(b"\n")
1233 indent_xml(stream, space=indent)
1234 ElementTree(stream).write(file, "utf-8", xml_declaration=False, method="xml")
1235 if indent is not None:
1236 file.write(b"\n")
1237 if return_bytes:
1238 return file.getvalue()
1239 return None
1240
1241 def to_xcal_element(self, element: Element, /) -> None:
1242 """Add the xCal representation of this component according to :rfc:`6321`."""
1243 self._validate_name()
1244 e_component = SubElement(element, self.name.lower())
1245 if len(self) > 0:
1246 e_properties = SubElement(e_component, "properties")
1247 for key, prop_list in self.sorted_items():
1248 for prop in prop_list if isinstance(prop_list, list) else [prop_list]:
1249 prop: VPROPERTY
1250 e_property = SubElement(e_properties, key.lower())
1251 prop.to_xcal(e_property)
1252 if self.subcomponents:
1253 e_components = SubElement(e_component, "components")
1254 for subcomponent in self.subcomponents:
1255 subcomponent.to_xcal(e_components)
1256
1257 @classmethod
1258 def from_xcal(cls, xcal: Element | bytes | Path | BinaryIO) -> list[Self]:
1259 """Parse xCal according to :rfc:`6321`.
1260
1261 Parameters:
1262 xcal: The xCal element or data to parse.
1263
1264 Returns:
1265 The parsed component.
1266
1267 Raises:
1268 ~icalendar.error.XCalParsingError: If the provided XML does not conform with :rfc:`6321`.
1269 TypeError: If ``xcal`` is not one of the valid types.
1270 xml.etree.ElementTree.ParseError: If the provided data is not valid XML.
1271
1272 Example:
1273
1274 Parse an xCal object from a bytes object in XML format.
1275
1276 >>> from icalendar import Calendar
1277 >>> xml = b'''<?xml version="1.0" encoding="utf-8"?>
1278 ... <icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0">
1279 ... <vcalendar>
1280 ... <properties>
1281 ... <prodid>
1282 ... <text>-//Example Inc.//Example Client//EN</text>
1283 ... </prodid>
1284 ... <version>
1285 ... <text>2.0</text>
1286 ... </version>
1287 ... </properties>
1288 ... </vcalendar>
1289 ... </icalendar>
1290 ... '''
1291 >>> cal = Calendar.from_xcal(xml)[0]
1292 >>> cal.prodid == '-//Example Inc.//Example Client//EN'
1293 True
1294
1295 """
1296 from icalendar.parser.xcal.component import XCalComponentParser
1297
1298 if isinstance(xcal, Path):
1299 xcal = xcal.open("rb")
1300 elif isinstance(xcal, bytes):
1301 xcal = BytesIO(xcal)
1302 if hasattr(xcal, "read"):
1303 element = config.parse_xml(xcal).getroot()
1304 elif isinstance(xcal, Element):
1305 element = xcal
1306 else:
1307 raise TypeError(
1308 f"Expected an XML Element, bytes, Path, or file object. Got {xcal}"
1309 )
1310 parser = XCalComponentParser(
1311 element, cls._get_component_factory(), cls.types_factory
1312 )
1313 components = []
1314 while not parser.is_finished():
1315 component = parser.parse_component()
1316 components.append(component)
1317 return components
1318
1319
1320def _node_from_jcal(jcal, starting_cls: type[Component]) -> tuple[Component, list]:
1321 """Parse a single jCal component without recursing into subcomponents.
1322
1323 Module-level helper for :meth:`Component.from_jcal`: it has no ties to a
1324 class or instance (the relevant class is passed in as ``starting_cls``), so
1325 it is a plain function rather than a (static) method.
1326
1327 Parameters:
1328 jcal: The jCal list for one component.
1329 starting_cls: The class used as the parser for structural validation
1330 before the component type is resolved from its name (the entry
1331 class for the root, the parent's resolved class for a child).
1332
1333 Returns:
1334 A ``(component, raw_subcomponents)`` tuple. The raw subcomponents are
1335 returned for the caller to walk iteratively.
1336
1337 Raises:
1338 ~error.JCalParsingError: If this component node is invalid. The path
1339 is relative to this node; callers prepend the nesting path.
1340 """
1341 if not isinstance(jcal, list) or len(jcal) != 3:
1342 raise JCalParsingError(
1343 "A component must be a list with 3 items.", starting_cls, value=jcal
1344 )
1345 name, properties, subcomponents = jcal
1346 if not isinstance(name, str):
1347 raise JCalParsingError(
1348 "The name must be a string.", starting_cls, path=[0], value=name
1349 )
1350 if name.upper() != starting_cls.name:
1351 # delegate to correct component class
1352 component_cls = starting_cls.get_component_class(name.upper())
1353 else:
1354 component_cls = starting_cls
1355 component = component_cls()
1356 if not isinstance(properties, list):
1357 raise JCalParsingError(
1358 "The properties must be a list.",
1359 component_cls,
1360 path=1,
1361 value=properties,
1362 )
1363 for i, prop in enumerate(properties):
1364 JCalParsingError.validate_property(prop, component_cls, path=[1, i])
1365 prop_name = prop[0]
1366 prop_value = prop[2]
1367 prop_cls: type[VPROPERTY] = component_cls.types_factory.for_property(
1368 prop_name, prop_value
1369 )
1370 with JCalParsingError.reraise_with_path_added(1, i):
1371 v_prop = prop_cls.from_jcal(prop)
1372 # jCal encodes the value type in the type field (``prop[2]``)
1373 # instead of as a ``VALUE`` parameter (RFC 7265). Restore that
1374 # parameter when the type differs from the property's default, so
1375 # explicit value types such as ``RDATE;VALUE=PERIOD`` or
1376 # ``TRIGGER;VALUE=DATE-TIME`` survive the round-trip (GH #1426).
1377 # A type equal to the default needs no VALUE parameter, and the
1378 # reserved ``unknown`` type must never become ``VALUE=UNKNOWN``
1379 # (RFC 7265, section 5.2).
1380 default_type = component_cls.types_factory.default_value_type(prop_name)
1381 if isinstance(prop_value, str) and prop_value.lower() not in (
1382 "unknown",
1383 default_type,
1384 ):
1385 v_prop.VALUE = prop_value.upper()
1386 elif "VALUE" in v_prop.params:
1387 del v_prop.VALUE
1388 component.add(prop_name, v_prop)
1389 if not isinstance(subcomponents, list):
1390 raise JCalParsingError(
1391 "The subcomponents must be a list.",
1392 component_cls,
1393 2,
1394 value=subcomponents,
1395 )
1396 return component, subcomponents
1397
1398
1399__all__ = ["Component"]