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