1"""parsing and generation of content lines"""
2
3import re
4
5from icalendar.parser.parameter import Parameters, closes_quoted_section
6from icalendar.parser.property import unescape_backslash, unescape_list_or_string
7from icalendar.parser.string import (
8 _escape_string,
9 _foldline,
10 _unescape_string,
11 validate_token,
12)
13from icalendar.parser_tools import DEFAULT_ENCODING, ICAL_TYPE, to_unicode
14
15# Equivalent to the natural ``(\r?\n)+[ \t]`` but without its quadratic cost:
16# the greedy run is re-tried at every line break of a long unfoldable block, so
17# a megabyte of bare newlines takes seconds. The leading lookbehinds pin a match
18# to the first line break of a run, making a failed attempt O(1) instead of a
19# full rescan, while matching exactly the same strings.
20UFOLD = re.compile(r"(?:(?<!\n)\r\n|(?<![\r\n])\n)(?:\r?\n)*[ \t]")
21NEWLINE = re.compile(r"\r?\n")
22
23OWS = " \t"
24# ``[ \t]*([;=])[ \t]*`` in one pass rescans a long whitespace run at every
25# position. Splitting it into two anchored passes (leading then trailing) keeps
26# the result identical but removes the quadratic blow-up.
27OWS_BEFORE_DELIMITER_RE = re.compile(r"(?<![ \t])[ \t]+([;=])")
28OWS_AFTER_DELIMITER_RE = re.compile(r"([;=])[ \t]+")
29
30
31def _strip_ows_around_delimiters(st: str, delimiters: str = ";=") -> str:
32 """Strip optional whitespace around delimiters outside of quoted sections,
33 respecting backslash escapes so that escaped delimiters are not treated as
34 separators.
35
36 This is a lenient parsing helper (used when strict=False) to support
37 iCalendar content lines that contain extra whitespace around tokens.
38 """
39 if not st:
40 return st
41
42 # Fast path for the common case in non-strict mode:
43 # no whitespace in the parameter section means there is nothing to normalize.
44 if " " not in st and "\t" not in st:
45 return st
46
47 # Fast regex-based path for simple parameter sections without quoting/escaping.
48 if delimiters == ";=" and '"' not in st and "\\" not in st:
49 st = OWS_BEFORE_DELIMITER_RE.sub(r"\1", st)
50 return OWS_AFTER_DELIMITER_RE.sub(r"\1", st).strip()
51
52 out: list[str] = []
53 pending_ws: list[str] = []
54 in_quotes = False
55 escaped = False
56 # True only if the last appended char was a raw delimiter.
57 last_was_delimiter = False
58
59 def flush_pending() -> None:
60 nonlocal pending_ws
61 if not pending_ws:
62 return
63 if not last_was_delimiter:
64 out.extend(pending_ws)
65 pending_ws.clear()
66
67 for i, ch in enumerate(st):
68 # Handle escaped character (the backslash set escaped in previous iteration)
69 if escaped:
70 flush_pending()
71 out.append(ch)
72 escaped = False
73 # a DQUOTE preceded by a literal backslash still closes the
74 # quoted section when a delimiter (or the end) follows it —
75 # the shape icalendar writes for values ending in a backslash
76 if ch == '"' and in_quotes and closes_quoted_section(st, i):
77 in_quotes = False
78 last_was_delimiter = False
79 continue
80
81 # Handle backslash to escape next character — outside quotes it
82 # protects delimiters, inside quotes it protects the closing quote
83 # (``\"``), so an escaped quote does not toggle the quote state.
84 if ch == "\\":
85 flush_pending()
86 out.append(ch)
87 escaped = True
88 last_was_delimiter = False
89 continue
90
91 # Handle quote toggling
92 if ch == '"' and not escaped:
93 in_quotes = not in_quotes
94 flush_pending()
95 out.append(ch)
96 last_was_delimiter = False
97 continue
98
99 # Whitespace outside quotes is buffered
100 if not in_quotes and not escaped and ch in OWS:
101 pending_ws.append(ch)
102 continue
103
104 # Raw delimiter (unescaped and outside quotes)
105 if not in_quotes and not escaped and ch in delimiters:
106 pending_ws.clear()
107 while out and out[-1] in OWS:
108 out.pop()
109 out.append(ch)
110 last_was_delimiter = True
111 continue
112
113 # Regular character
114 flush_pending()
115 out.append(ch)
116 last_was_delimiter = False
117
118 if pending_ws and not last_was_delimiter:
119 out.extend(pending_ws)
120
121 return "".join(out).strip()
122
123
124class Contentline(str):
125 r"""A content line is a string that can be folded and parsed into parts.
126
127 Raises:
128 ValueError: If the value contains ``\n``.
129
130 """
131
132 __slots__ = ("strict",)
133
134 def __new__(cls, value, strict=False, encoding=DEFAULT_ENCODING):
135 value = to_unicode(value, encoding=encoding)
136 if "\n" in value:
137 raise ValueError(
138 "Content line can not contain unescaped new line characters."
139 )
140 self = super().__new__(cls, value)
141 self.strict = strict
142 return self
143
144 @classmethod
145 def from_parts(
146 cls,
147 name: ICAL_TYPE,
148 params: Parameters,
149 values,
150 sorted: bool = True, # noqa: A002
151 ):
152 r"""Turn a parts into a content line.
153
154 Raises:
155 TypeError: If ``params`` is not type ``Parameters``.
156
157 """
158 if not isinstance(params, Parameters):
159 raise TypeError(f"Expected Parameters, got {type(params).__name__}")
160 if hasattr(values, "to_ical"):
161 values = values.to_ical()
162 else:
163 from icalendar.prop import vText
164
165 values = vText(values).to_ical()
166 # elif isinstance(values, basestring):
167 # values = escape_char(values)
168
169 # TODO: after unicode only, remove this
170 # Convert back to unicode, after to_ical encoded it.
171 name = to_unicode(name)
172 values = to_unicode(values)
173 if params:
174 params = to_unicode(params.to_ical(sorted=sorted))
175 if params:
176 # some parameter values can be skipped during serialization
177 return cls(f"{name};{params}:{values}")
178 return cls(f"{name}:{values}")
179
180 def raw_parts(self) -> tuple[str, Parameters, str]:
181 """Split the line into ``name``, ``parameters``, and raw ``values`` parts.
182
183 This is :meth:`parts` without the unescaping: the values are returned
184 verbatim, preserving both backslash sequences and URL encoding. It is
185 used for :rfc:`7265` ``UNKNOWN`` values, whose real value type—and
186 therefore whose escaping rules—are not known.
187
188 See :meth:`parts` for the parts themselves and for examples.
189 """
190 try:
191 name_split: int | None = None
192 value_split: int | None = None
193 in_quotes: bool = False
194 escaped: bool = False
195
196 for i, ch in enumerate(self):
197 if escaped:
198 # the previous character was an escaping backslash:
199 # outside quotes it protects a delimiter, inside quotes
200 # it protects an escaped quote (``\"``), which must not
201 # toggle the quote state (:rfc:`2445`-style input). A
202 # DQUOTE that closes the section is different: preceded
203 # by a literal backslash and followed by a delimiter or
204 # the end of the line, it closes (values ending in a
205 # backslash, as icalendar itself writes them).
206 escaped = False
207 if ch == '"' and closes_quoted_section(self, i):
208 in_quotes = False
209 elif ch == "\\":
210 escaped = True
211 elif ch == '"':
212 in_quotes = not in_quotes
213 elif not in_quotes and ch in ":;":
214 # Find first delimiter for name
215 if name_split is None:
216 name_split = i
217 # Find value delimiter (first colon)
218 if ch == ":" and value_split is None:
219 value_split = i
220
221 # Validate parsing results
222 if not value_split:
223 # No colon found - value is empty, use end of string
224 value_split = len(self)
225
226 # Extract name - if no delimiter,
227 # take whole string for validate_token to reject
228 name = self[:name_split] if name_split else self
229 if not self.strict:
230 name = re.sub(r"[ \t]+", "", name.strip())
231 validate_token(name)
232
233 if not name_split or name_split + 1 == value_split:
234 # No delimiter or empty parameter section
235 raise ValueError("Invalid content line") # noqa: TRY301
236 # Parse parameters - they still need to be escaped/unescaped
237 # for proper handling of commas, semicolons, etc. in parameter values
238 raw_param_str = self[name_split + 1 : value_split]
239 if not self.strict:
240 raw_param_str = _strip_ows_around_delimiters(raw_param_str)
241 param_str = _escape_string(raw_param_str)
242 params = Parameters.from_ical(param_str, strict=self.strict)
243 params = Parameters(
244 (_unescape_string(key), unescape_list_or_string(value))
245 for key, value in iter(params.items())
246 )
247 values = self[value_split + 1 :]
248 except ValueError as exc:
249 raise ValueError(
250 f"Content line could not be parsed into parts: '{self}': {exc}"
251 ) from exc
252 return (name, params, values)
253
254 def parts(self) -> tuple[str, Parameters, str]:
255 """Split the line into ``name``, ``parameters``, and unescaped ``values`` parts.
256
257 Properly handles escaping with backslashes and double-quote sections
258 to avoid corrupting URL-encoded characters in values.
259
260 The backslash sequences in the values are unescaped, as for the values
261 of ``TEXT`` properties, while URL encoding is preserved. Use
262 :meth:`raw_parts` to get the values verbatim instead.
263
264 Examples:
265
266 With parameter:
267
268 .. code-block:: ics
269
270 DESCRIPTION;ALTREP="cid:part1.0001@example.org":The Fall'98 Wild
271
272 Without parameters:
273
274 .. code-block:: ics
275
276 DESCRIPTION:The Fall'98 Wild
277 """
278 name, params, values = self.raw_parts()
279 return (name, params, unescape_backslash(values))
280
281 def value_separator_index(self) -> int:
282 r"""Return the index of the colon that separates the value.
283
284 This is the first colon that is not inside a quoted parameter section.
285
286 A colon inside a quoted parameter value is skipped, for example,
287 ``ALTREP="http://x"``. A colon that belongs to the value is not
288 mistaken for the colon separator, since TEXT doesn't escape ``:``.
289
290 An escaped double quote (``\"``) inside a quoted section doesn't
291 close it. :rfc:`2445#section-4.3.11`-compliant input, such as
292 ``CN="Qu\"ote"``, both keeps its quote state and the separator after
293 it is still found.
294
295 A DQUOTE preceded by a literal backslash closes the section when the
296 separator or the end of the line follows it. This is the shape
297 icalendar itself writes for values ending in a backslash.
298
299 Returns:
300 An integer representing the index position of the separator,
301 or ``-1`` if there is none.
302 """
303 in_quotes = False
304 escaped = False
305 for i, ch in enumerate(self):
306 if in_quotes and escaped:
307 escaped = False
308 if ch == '"' and closes_quoted_section(self, i):
309 in_quotes = False
310 elif in_quotes and ch == "\\":
311 escaped = True
312 elif ch == '"':
313 in_quotes = not in_quotes
314 elif ch == ":" and not in_quotes:
315 return i
316 return -1
317
318 @classmethod
319 def from_ical(cls, ical, strict=False):
320 """Unfold the content lines in an iCalendar into long content lines."""
321 ical = to_unicode(ical)
322 # a fold is carriage return followed by either a space or a tab
323 return cls(UFOLD.sub("", ical), strict=strict)
324
325 def to_ical(self):
326 """Long content lines are folded so they are less than 75 characters
327 wide.
328 """
329 return _foldline(self).encode(DEFAULT_ENCODING)
330
331
332class Contentlines(list[Contentline]):
333 """I assume that iCalendar files generally are a few kilobytes in size.
334 Then this should be efficient. for Huge files, an iterator should probably
335 be used instead.
336 """
337
338 def to_ical(self):
339 """Simply join self."""
340 return b"\r\n".join(line.to_ical() for line in self if line) + b"\r\n"
341
342 @classmethod
343 def from_ical(cls, st):
344 """Parses a string into content lines."""
345 st = to_unicode(st)
346 try:
347 # a fold is carriage return followed by either a space or a tab
348 unfolded = UFOLD.sub("", st)
349 lines = cls(Contentline(line) for line in NEWLINE.split(unfolded) if line)
350 lines.append("") # '\r\n' at the end of every content line
351 except Exception as e:
352 raise ValueError("Expected StringType with content lines") from e
353 return lines
354
355
356__all__ = ["Contentline", "Contentlines"]