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