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