1"""Parsing a component's iCalendar data."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, ClassVar
6
7from icalendar.error import ICalParsingError, InvalidToken
8from icalendar.parser.content_line import Contentline, Contentlines
9from icalendar.parser.property import split_on_unescaped_comma
10from icalendar.prop import vBroken
11from icalendar.timezone import tzp
12
13if TYPE_CHECKING:
14 from icalendar.cal import Component, ComponentFactory
15 from icalendar.parser.parameter import Parameters
16 from icalendar.prop import VPROPERTY, TypesFactory
17
18
19class ComponentIcalParser:
20 """A parser for a component's iCalendar data.
21
22 This uses the template method pattern, where the main parsing
23 logic can be refined in subclasses.
24 """
25
26 datetime_names: ClassVar[tuple[str, ...]] = (
27 "DTSTART",
28 "DTEND",
29 "RECURRENCE-ID",
30 "DUE",
31 "RDATE",
32 "EXDATE",
33 )
34 """Names to check for TZID parameter when parsing datetimes.
35
36 Their ``from_ical`` methods take an optional ``tzid`` argument,
37 which is used if the property has a TZID parameter.
38 """
39
40 def __init__(
41 self,
42 data: bytes | str | list[Contentline],
43 component_factory: ComponentFactory,
44 types_factory: TypesFactory,
45 ) -> None:
46 """Initialize the parser with the raw data.
47
48 Parameters:
49 data: The raw iCalendar data to parse, either as bytes
50 or a list of content lines.
51 component_factory: The factory to use for creating components.
52 types_factory: The factory to use for creating property values.
53 """
54 self._data = data
55 self._component_factory = component_factory
56 self._types_factory = types_factory
57 self._tzp = tzp
58
59 _content_lines: list[Contentline]
60
61 def contains_component(self, name: str) -> bool:
62 """Check if the parser contains a component."""
63 self.initialize_parsing()
64 begin_line = "BEGIN:" + name.upper()
65 return any(
66 len(content_line) == len(begin_line) and content_line.upper() == begin_line
67 for content_line in self._content_lines
68 )
69
70 def contains_uid(self, uid: str) -> bool:
71 """Determines whether the component contains a ``uid``.
72
73 Returns:
74 ``True`` if the component contains a ``uid``, else ``False``.
75 """
76 self.initialize_parsing()
77 return any(uid in line for line in self._content_lines)
78
79 def initialize_parsing(self):
80 self._stack: list[Component] = []
81 self._components: list[Component] = []
82 self._data = self._content_lines = (
83 self._data
84 if isinstance(self._data, list)
85 else Contentlines.from_ical(self._data)
86 )
87 self._content_lines_iterator = iter(self._content_lines)
88
89 def handle_line_parse_error(self, exception: Exception):
90 """Handle a line parsing error."""
91 # if unable to parse a line within a component
92 # that ignores exceptions, mark the component
93 # as broken and skip the line. otherwise raise.
94 component = self.component
95 if not component or not component.ignore_exceptions:
96 raise exception
97 component.errors.append((None, str(exception)))
98
99 def get_component_class(self, name: str) -> type[Component]:
100 """Get the component class for a name from the ``BEGIN`` line.
101
102 Parameters:
103 name: Required. The component name from the ``BEGIN`` line.
104
105 Raises:
106 ~icalendar.error.ICalParsingError: If the name is not a valid token.
107 """
108 try:
109 return self._component_factory.get_component_class(name)
110 except InvalidToken as error:
111 raise ICalParsingError(
112 "The component name must be a valid iCalendar token",
113 value=name,
114 ) from error
115
116 def handle_begin_component(self, vals: str) -> None:
117 """Handle the beginning of a component."""
118 # try and create one of the components defined in the spec,
119 # otherwise get a general Components for robustness.
120 c_name = vals.upper()
121 c_class = self.get_component_class(vals)
122 # If component factory cannot resolve ``c_name``, the generic
123 # ``Component`` class is used which does not have the name set.
124 # That's opposed to the usage of ``cls``, which represents a
125 # more concrete subclass with a name set (e.g. VCALENDAR).
126 component = c_class()
127 if not getattr(component, "name", ""): # undefined components
128 component.name = c_name
129 self._stack.append(component)
130
131 def handle_end_component(self, vals: str) -> None:
132 """Handle the end of a component."""
133 # we are done adding properties to this component
134 # so pop it from the stack and add it to the new top.
135 if not self._stack:
136 # The stack is currently empty, the input must be invalid
137 raise ValueError("END encountered without an accompanying BEGIN!")
138
139 component = self._stack.pop()
140 if not self._stack: # we are at the end
141 self._components.append(component)
142 else:
143 self._stack[-1].add_component(component)
144 if vals.upper() == "VTIMEZONE" and "TZID" in component:
145 tzp.cache_timezone_component(component)
146
147 def prepare_components(self) -> None:
148 """Prepare the parsed components.
149
150 This is called when all components are parsed.
151 """
152
153 def parse(self) -> list[Component]:
154 """Parse the raw data."""
155 self.initialize_parsing()
156 self.parse_content_lines()
157 self.prepare_components()
158 return self._components
159
160 def parse_content_lines(self) -> None:
161 """Parse the content lines."""
162 for line in self._content_lines_iterator:
163 if not line:
164 continue
165 try:
166 name, params, vals = line.parts()
167 except ValueError as e:
168 self.handle_line_parse_error(e)
169 continue
170
171 uname = name.upper()
172 if uname == "BEGIN":
173 self.handle_begin_component(vals)
174 elif uname == "END":
175 self.handle_end_component(vals)
176 else:
177 self.handle_property(uname, params, vals, line)
178
179 @property
180 def component(self) -> Component | None:
181 return self._stack[-1] if self._stack else None
182
183 def get_factory_for_property(self, name: str, params: Parameters) -> VPROPERTY:
184 """Get the factory for a property."""
185 return self._types_factory.for_property(name, params.value)
186
187 def handle_property(
188 self, name: str, params: Parameters, vals: str, line: Contentline
189 ) -> None:
190 """Handle a property line.
191
192 Add properties to the top of the current stack.
193
194 Parameters:
195 name: The name of the property, uppercased.
196 params: The parameters of the property.
197 vals: The value of the property.
198 line: The original content line.
199 """
200 # Extract VALUE parameter if present
201 if not self.component:
202 # only accept X-COMMENT at the end of the .ics file
203 # ignore these components in parsing
204 if name == "X-COMMENT":
205 return
206 raise ValueError(f'Property "{name}" does not have a parent component.')
207 # Determine TZID for datetime properties
208 tzid = params.get("TZID") if params and name in self.datetime_names else None
209
210 # Handle special cases for value list preparation
211 if name == "CATEGORIES":
212 if self.handle_categories(params, vals, line):
213 return
214 # Fallback to normal processing if we can't find colon
215 vals_list = [vals]
216 elif name == "FREEBUSY":
217 # Handle FREEBUSY comma-separated values
218 vals_list = vals.split(",")
219 # Workaround broken ICS files with empty RDATE
220 # (not EXDATE - let it parse and fail)
221 elif name == "RDATE" and vals == "":
222 vals_list = []
223 else:
224 factory = self.get_factory_for_property(name, params)
225 # Value types that must not be unescaped provide their own value.
226 get_value_from_content_line = getattr(
227 factory, "get_value_from_content_line", None
228 )
229 if get_value_from_content_line is not None:
230 vals = get_value_from_content_line(line)
231 vals_list = [vals]
232
233 # Parse all properties eagerly
234 for val in vals_list:
235 self.parse_and_add_property(name, params, val, tzid, line)
236
237 def parse_and_add_property(
238 self,
239 name: str,
240 params: Parameters,
241 val: str,
242 tzid: str | None,
243 line: Contentline,
244 ):
245 """Parse a property value and add it to the current component."""
246 factory = self.get_factory_for_property(name, params)
247 try:
248 if tzid:
249 parsed_val = factory.from_ical(val, tzid)
250 else:
251 parsed_val = factory.from_ical(val)
252 except (ValueError, TypeError) as e:
253 self.handle_property_parse_error(e, name, params, val, line)
254 else:
255 vals_inst = factory(parsed_val)
256 vals_inst.params = params
257 self.component.add(name, vals_inst, encode=False)
258
259 def handle_property_parse_error(
260 self,
261 exception: Exception,
262 name: str,
263 params: Parameters,
264 val: str,
265 line: Contentline,
266 ):
267 """Handle the parse error for a property."""
268 if not self.component.ignore_exceptions and not name[:2].upper() == "X-":
269 raise exception
270 # Error-tolerant mode: create vBroken
271 factory = self.get_factory_for_property(name, params)
272 expected_type = getattr(factory, "__name__", "unknown")
273 broken_prop = vBroken.from_parse_error(
274 raw_value=val,
275 params=params,
276 property_name=name,
277 expected_type=expected_type,
278 error=exception,
279 )
280 self.component.errors.append((name, str(exception)))
281 self.component.add(name, broken_prop, encode=0)
282
283 def handle_categories(
284 self, params: Parameters, vals: str, line: Contentline
285 ) -> bool:
286 """Handle the special case of CATEGORIES property.
287
288 Returns:
289 ``True`` if handled, else ``False``.
290 """
291 # Special handling for CATEGORIES - need raw value
292 # before unescaping to properly split on unescaped commas
293 line_str = str(line)
294 # The value separator is the first colon outside a quoted parameter
295 # section. ``rfind`` here would pick a colon that belongs to the value
296 # (``TEXT`` does not escape ``:``) and truncate the categories.
297 colon_idx = line.value_separator_index()
298 if colon_idx > 0:
299 raw_value = line_str[colon_idx + 1 :]
300 # Parse categories immediately (not lazily) for both
301 # strict and tolerant components.
302 # CATEGORIES needs special comma handling
303 try:
304 category_list = split_on_unescaped_comma(raw_value)
305 factory = self.get_factory_for_property("CATEGORIES", params)
306 vals_inst = factory(category_list)
307 vals_inst.params = params
308 self.component.add("CATEGORIES", vals_inst, encode=0)
309 except ValueError as e:
310 self.handle_property_parse_error(
311 e, "CATEGORIES", params, raw_value, line
312 )
313 return True
314 return False