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