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