1"""Functions for parsing parameters."""
2
3from __future__ import annotations
4
5import functools
6import os
7import re
8from datetime import datetime, time
9from typing import TYPE_CHECKING, Any, Protocol
10from xml.etree.ElementTree import Element
11
12from icalendar.caselessdict import CaselessDict
13from icalendar.compatibility import Self, deprecate_for_version_9
14from icalendar.error import JCalParsingError
15from icalendar.parser.string import validate_token
16from icalendar.parser_tools import (
17 DEFAULT_ENCODING,
18 SEQUENCE_TYPES,
19)
20from icalendar.timezone.tzid import tzid_from_dt
21
22if TYPE_CHECKING:
23 from collections.abc import Callable, KeysView, Sequence
24
25 from icalendar.enums import VALUE
26 from icalendar.parser.xcal.adapter import ElementAdapter
27 from icalendar.prop import VPROPERTY
28
29
30class HasToIcal(Protocol):
31 """Protocol for objects with a to_ical method."""
32
33 def to_ical(self) -> bytes:
34 """Convert to iCalendar format."""
35 ...
36
37
38def param_value(
39 value: Sequence[str] | str | HasToIcal, always_quote: bool = False
40) -> str:
41 """Convert a parameter value to its iCalendar representation.
42
43 Applies :rfc:`6868` escaping and optionally quotes the value according
44 to :rfc:`5545` parameter value formatting rules.
45
46 Parameters:
47 value: The parameter value to convert. Can be a sequence, string, or
48 object with a ``to_ical()`` method.
49 always_quote: If ``True``, always enclose the value in double quotes.
50 Defaults to ``False`` (only quote when necessary).
51
52 Returns:
53 The formatted parameter value, escaped and quoted as needed.
54 """
55 if isinstance(value, SEQUENCE_TYPES):
56 return q_join(map(_rfc_6868_escape, value), always_quote=always_quote)
57 if isinstance(value, str):
58 return dquote(_rfc_6868_escape(value), always_quote=always_quote)
59 return dquote(_rfc_6868_escape(value.to_ical().decode(DEFAULT_ENCODING)))
60
61
62# Could be improved
63
64
65UNSAFE_CHAR = re.compile('[\x00-\x08\x0a-\x1f\x7f",:;]')
66QUNSAFE_CHAR = re.compile('[\x00-\x08\x0a-\x1f\x7f"]')
67
68
69def validate_param_value(value: str, quoted: bool = True) -> None:
70 """Validate a parameter value for unsafe characters.
71
72 Checks parameter values for characters that are not allowed according to
73 :rfc:`5545`. Uses different validation rules for quoted and unquoted values.
74
75 Parameters:
76 value: The parameter value to validate.
77 quoted: If ``True``, validate as a quoted value (allows more characters).
78 If ``False``, validate as an unquoted value (stricter).
79 Defaults to ``True``.
80
81 Raises:
82 ValueError: If the value contains unsafe characters for its quote state.
83 """
84 validator = QUNSAFE_CHAR if quoted else UNSAFE_CHAR
85 if validator.findall(value):
86 raise ValueError(value)
87
88
89# chars presence of which in parameter value will be cause the value
90# to be enclosed in double-quotes
91QUOTABLE = re.compile("[,;:’]") # noqa: RUF001
92
93
94def dquote(val: str, always_quote: bool = False) -> str:
95 r"""Enclose parameter values in double quotes when needed.
96
97 Parameter values containing special characters ``,``, ``;``,
98 ``:`` or ``'`` must be enclosed
99 in double quotes according to :rfc:`5545`. Double-quote characters in the
100 value are replaced with single quotes since they're forbidden in parameter
101 values. Values ending with a backslash ``\`` are quoted, too, because
102 the parser would otherwise read it as escaping the following delimiter.
103
104 Parameters:
105 val: The parameter value to quote.
106 always_quote: If ``True``, always enclose in quotes regardless of content.
107 Defaults to ``False`` (only quote when necessary).
108
109 Returns:
110 The value, enclosed in double quotes if needed or requested.
111 """
112 # a double-quote character is forbidden to appear in a parameter value
113 # so replace it with a single-quote character
114 val = val.replace('"', "'")
115 if QUOTABLE.search(val) or val.endswith("\\") or always_quote:
116 return f'"{val}"'
117 return val
118
119
120def unescape_quoted_param_value(value: str) -> str:
121 r"""Unescape :rfc:`2445#section-4.3.11`-compliant escapes in a quoted
122 parameter value.
123
124 ``\"`` becomes a literal double quote, and ``\\`` becomes a literal
125 backslash. A lone backslash is kept as-is. It's a valid
126 :rfc:`5545#section-3.1` QSAFE-CHAR with no escape meaning. Values are
127 scanned from left to right, resolving ``\\\"`` to ``\"``.
128 """
129 out = []
130 i = 0
131 length = len(value)
132 while i < length:
133 ch = value[i]
134 if ch == "\\" and i + 1 < length and value[i + 1] in ('"', "\\"):
135 out.append(value[i + 1])
136 i += 2
137 else:
138 out.append(ch)
139 i += 1
140 return "".join(out)
141
142
143# parsing helper
144
145# A DQUOTE preceded by a backslash still closes its quoted section when a
146# structural delimiter or the end of the string follows it. icalendar
147# itself writes parameter values that end in a backslash as ``"…\"`` (see
148# :func:`param_value`). :rfc:`5545` and :rfc:`6868` give the backslash no
149# escape meaning inside parameter values. A backslash-escaped DQUOTE is
150# honored as value content only when more value text follows it, the way
151# :rfc:`2445`-style producers emit ``CN="Qu\"ote"``.
152_STRUCTURAL_DELIMITERS = frozenset(";:,")
153
154
155def closes_quoted_section(text: str, quote_index: int) -> bool:
156 """Whether the DQUOTE at ``quote_index`` closes its quoted section.
157
158 The DQUOTE may be preceded by a literal backslash. If the next
159 non-whitespace character after it is a structural delimiter (``;``,
160 ``:``, or ``,``), nothing but whitespace, or the end of the string
161 follows, then the backslash is content and the DQUOTE closes the quoted
162 section. A DQUOTE followed by other text is an escaped quote inside
163 the value instead.
164 """
165 for ch in text[quote_index + 1 :]:
166 if ch in " \t":
167 continue
168 return ch in _STRUCTURAL_DELIMITERS
169 return True
170
171
172def q_split(st: str, sep: str = ",", maxsplit: int = -1) -> list[str]:
173 """Split a string on a separator, respecting double quotes.
174
175 Splits the string on the separator character, but ignores separators that
176 appear inside double-quoted sections. This is needed for parsing parameter
177 values that may contain quoted strings.
178
179 Parameters:
180 st: The string to split.
181 sep: The separator character. Defaults to ``,``.
182 maxsplit: Maximum number of splits to perform. If ``-1`` (default),
183 then perform all possible splits.
184
185 Returns:
186 The split string parts.
187
188 Examples:
189 .. code-block:: pycon
190
191 >>> from icalendar.parser import q_split
192 >>> q_split('a,b,c')
193 ['a', 'b', 'c']
194 >>> q_split('a,"b,c",d')
195 ['a', '"b,c"', 'd']
196 >>> q_split('a;b;c', sep=';')
197 ['a', 'b', 'c']
198 """
199 if maxsplit == 0:
200 return [st]
201
202 result = []
203 cursor = 0
204 length = len(st)
205 inquote = 0
206 escaped = False
207 splits = 0
208 for i, ch in enumerate(st):
209 if inquote and escaped:
210 # an escaped character inside a quoted section is content:
211 # ``\"`` does not close the quote (RFC 2445-style input) —
212 # unless a separator or the end of the string follows, which
213 # makes the backslash a literal one and the DQUOTE the closer
214 # (values ending in a backslash, as icalendar writes them).
215 escaped = False
216 if ch == '"' and closes_quoted_section(st, i):
217 inquote = not inquote
218 elif inquote and ch == "\\":
219 escaped = True
220 elif ch == '"':
221 inquote = not inquote
222 if not inquote and ch == sep:
223 result.append(st[cursor:i])
224 cursor = i + 1
225 splits += 1
226 if i + 1 == length or splits == maxsplit:
227 result.append(st[cursor:])
228 break
229 return result
230
231
232def q_join(lst: Sequence[str], sep: str = ",", always_quote: bool = False) -> str:
233 """Join a list with a separator, quoting items as needed.
234
235 Joins list items with the separator, applying :func:`dquote` to each item
236 to add double quotes when they contain special characters.
237
238 Parameters:
239 lst: The list of items to join.
240 sep: The separator to use. Defaults to ``,``.
241 always_quote: If ``True``, always quote all items. Defaults to ``False``
242 (only quote when necessary).
243
244 Returns:
245 The joined string with items quoted as needed.
246
247 Examples:
248 .. code-block:: pycon
249
250 >>> from icalendar.parser import q_join
251 >>> q_join(['a', 'b', 'c'])
252 'a,b,c'
253 >>> q_join(['plain', 'has,comma'])
254 'plain,"has,comma"'
255 """
256 return sep.join(dquote(itm, always_quote=always_quote) for itm in lst)
257
258
259def _single_string_parameter(upper: bool = False):
260 """Create a parameter getter/setter for a single string parameter.
261
262 Parameters:
263 upper: Convert the return value of the property getter to uppercase.
264
265 Returns:
266 The property for the parameter.
267 """
268
269 def decorator(func: Callable) -> property:
270 name = func.__name__
271
272 @functools.wraps(func)
273 def fget(self: Parameters):
274 """Get the value."""
275 value = self.get(name)
276 if value is not None and upper:
277 value = value.upper()
278 return value
279
280 def fset(self: Parameters, value: str | None):
281 """Set the value"""
282 if value is None:
283 fdel(self)
284 else:
285 if upper:
286 value = value.upper()
287 self[name] = value
288
289 def fdel(self: Parameters):
290 """Delete the value."""
291 self.pop(name, None)
292
293 return property(fget, fset, fdel, doc=func.__doc__)
294
295 return decorator
296
297
298single_string_parameter = deprecate_for_version_9(_single_string_parameter)
299
300
301class Parameters(CaselessDict[str | list[str]]):
302 """Parser and generator of Property parameter strings.
303
304 It knows nothing of datatypes.
305 Its main concern is textual structure.
306
307 Examples:
308
309 Modify parameters:
310
311 .. code-block:: pycon
312
313 >>> from icalendar import Parameters
314 >>> params = Parameters()
315 >>> params['VALUE'] = 'TEXT'
316 >>> params.value
317 'TEXT'
318 >>> params
319 Parameters({'VALUE': 'TEXT'})
320
321 Create new parameters:
322
323 .. code-block:: pycon
324
325 >>> params = Parameters(value="BINARY")
326 >>> params.value
327 'BINARY'
328
329 Set a default:
330
331 .. code-block:: pycon
332
333 >>> params = Parameters(value="BINARY", default_value="TEXT")
334 >>> params
335 Parameters({'VALUE': 'BINARY'})
336
337 """
338
339 def __init__(self, *args: Any, **kwargs: Any) -> None:
340 """Create new parameters."""
341 if args and args[0] is None:
342 # allow passing None
343 args = args[1:]
344 defaults = {
345 key[8:]: kwargs.pop(key)
346 for key in list(kwargs.keys())
347 if key.lower().startswith("default_")
348 }
349 super().__init__(*args, **kwargs)
350 for key, value in defaults.items():
351 self.setdefault(key, value)
352
353 # The following paremeters must always be enclosed in double quotes
354 always_quoted = (
355 "ALTREP",
356 "DELEGATED-FROM",
357 "DELEGATED-TO",
358 "DIR",
359 "MEMBER",
360 "SENT-BY",
361 # Part of X-APPLE-STRUCTURED-LOCATION
362 "X-ADDRESS",
363 "X-TITLE",
364 # RFC 9253
365 "LINKREL",
366 )
367 # this is quoted should one of the values be present
368 quote_also = {
369 # This is escaped in the RFC
370 "CN": " '",
371 }
372
373 def params(self) -> KeysView[str]:
374 """In RFC 5545 keys are called parameters, so this is to be consitent
375 with the naming conventions.
376 """
377 return self.keys()
378
379 def to_ical(self, sorted: bool = True) -> bytes: # noqa: A002
380 """Returns an :rfc:`5545` representation of the parameters.
381
382 Parameters:
383 sorted (bool): Sort the parameters before encoding.
384 exclude_utc (bool): Exclude TZID if it is set to ``"UTC"``
385 """
386 result = []
387 items = list(self.items())
388 if sorted:
389 items.sort()
390
391 for key, value in items:
392 if key == "TZID" and value == "UTC":
393 # The "TZID" property parameter MUST NOT be applied to DATE-TIME
394 # properties whose time values are specified in UTC.
395 continue
396 upper_key = key.upper()
397 check_quoteable_characters = self.quote_also.get(key.upper())
398 always_quote = upper_key in self.always_quoted or (
399 check_quoteable_characters
400 and any(c in value for c in check_quoteable_characters)
401 )
402 quoted_value = param_value(value, always_quote=always_quote)
403 quoted_value = quoted_value.encode(DEFAULT_ENCODING)
404 # CaselessDict keys are always unicode
405 result.append(upper_key.encode(DEFAULT_ENCODING) + b"=" + quoted_value)
406 return b";".join(result)
407
408 @classmethod
409 def from_ical(cls, st: str, strict: bool = False) -> Self:
410 """Parses the parameter format from ical text format."""
411
412 # parse into strings
413 result = cls()
414 for param in q_split(st, ";"):
415 try:
416 key, val = q_split(param, "=", maxsplit=1)
417 validate_token(key)
418 # Property parameter values that are not in quoted
419 # strings are case insensitive.
420 string_values = []
421 for v in q_split(val, ","):
422 if v.startswith('"') and v.endswith('"') and len(v) >= 2:
423 # validate the still-escaped form: ``\"`` pairs are
424 # removed first so the literal quote they unescape to
425 # does not trip QUNSAFE_CHAR, while a raw unescaped
426 # quote still does. Exactly the surrounding quotes
427 # are removed — a trailing ``\"`` keeps its quote,
428 # which ``str.strip('"')`` would also eat.
429 v2 = v[1:-1]
430 validate_param_value(v2.replace('\\"', ""), quoted=True)
431 string_values.append(
432 _rfc_6868_unescape(unescape_quoted_param_value(v2))
433 )
434 else:
435 validate_param_value(v, quoted=False)
436 if strict:
437 string_values.append(_rfc_6868_unescape(v.upper()))
438 else:
439 string_values.append(_rfc_6868_unescape(v))
440
441 if not string_values:
442 string_values = [val]
443 result[key] = (
444 string_values[0] if len(string_values) == 1 else string_values
445 )
446 except ValueError as exc: # noqa: PERF203
447 raise ValueError(
448 f"{param!r} is not a valid parameter string: {exc}"
449 ) from exc
450 return result
451
452 @_single_string_parameter(upper=True)
453 def value(self) -> VALUE | str | None:
454 """The VALUE parameter from :rfc:`5545`.
455
456 Description:
457 This parameter specifies the value type and format of
458 the property value. The property values MUST be of a single value
459 type. For example, a "RDATE" property cannot have a combination
460 of DATE-TIME and TIME value types.
461
462 If the property's value is the default value type, then this
463 parameter need not be specified. However, if the property's
464 default value type is overridden by some other allowable value
465 type, then this parameter MUST be specified.
466
467 Applications MUST preserve the value data for x-name and iana-
468 token values that they don't recognize without attempting to
469 interpret or parse the value data.
470
471 For convenience, using this property, the value will be converted to
472 an uppercase string.
473
474 .. code-block:: pycon
475
476 >>> from icalendar import Parameters
477 >>> params = Parameters()
478 >>> params.value = "unknown"
479 >>> params
480 Parameters({'VALUE': 'UNKNOWN'})
481
482 """
483
484 def _parameter_value_to_jcal(
485 self, value: str | float | list | VPROPERTY
486 ) -> str | int | float | list[str] | list[int] | list[float]:
487 """Convert a parameter value to jCal format.
488
489 Parameters:
490 value: The parameter value
491
492 Returns:
493 The jCal representation of the parameter value
494 """
495 if isinstance(value, list):
496 return [self._parameter_value_to_jcal(v) for v in value]
497 if hasattr(value, "to_jcal"):
498 # proprty values respond to this
499 jcal = value.to_jcal()
500 # we only need the value part
501 if len(jcal) == 4:
502 return jcal[3]
503 return jcal[3:]
504 for t in (int, float, str):
505 if isinstance(value, t):
506 return t(value)
507 raise TypeError(
508 "Unsupported parameter value type for jCal conversion: "
509 f"{type(value)} {value!r}"
510 )
511
512 def to_jcal(self, exclude_utc=False) -> dict[str, str]:
513 """Return the jCal representation of the parameters.
514
515 Parameters:
516 exclude_utc (bool): Exclude the TZID parameter if it is UTC
517 """
518 jcal = {
519 k.lower(): self._parameter_value_to_jcal(v)
520 for k, v in self.items()
521 if k.lower() != "value"
522 }
523 if exclude_utc and jcal.get("tzid") == "UTC":
524 del jcal["tzid"]
525 return jcal
526
527 @_single_string_parameter()
528 def tzid(self) -> str | None:
529 """The TZID parameter from :rfc:`5545`."""
530
531 def is_utc(self) -> bool:
532 """Whether the TZID parameter is UTC."""
533 return self.tzid == "UTC"
534
535 def update_tzid_from(
536 self, dt: datetime | time | tuple[datetime, Any] | Any
537 ) -> None:
538 """Update the TZID parameter from a datetime object.
539
540 This sets the TZID parameter or deletes it according to the datetime.
541 :rfc:`5545#section-3.2.19` prohibits TZID on UTC datetimes,
542 which use the ``Z`` suffix instead.
543 """
544 if isinstance(dt, tuple) and len(dt) >= 1:
545 dt = dt[0]
546 if isinstance(dt, (datetime, time)):
547 tzid = tzid_from_dt(dt)
548 # UTC uses Z suffix and does not appear as TZID parameter
549 if tzid == "UTC":
550 del self.tzid
551 else:
552 self.tzid = tzid
553
554 @classmethod
555 def from_jcal(cls, jcal: dict[str : str | list[str]]):
556 """Parse jCal parameters."""
557 if not isinstance(jcal, dict):
558 raise JCalParsingError("The parameters must be a mapping.", cls)
559 result = cls()
560 for name, value in jcal.items():
561 if not isinstance(name, str):
562 raise JCalParsingError(
563 "All parameter names must be strings.", cls, value=name
564 )
565 JCalParsingError.validate_jcal_token(name, "parameter name", cls)
566 if not (
567 (
568 isinstance(value, list)
569 and all(isinstance(v, (str, int, float)) for v in value)
570 and value
571 )
572 or isinstance(value, (str, int, float))
573 ):
574 raise JCalParsingError(
575 "Parameter values must be a string, integer or "
576 "float or a list of those.",
577 cls,
578 name,
579 value=value,
580 )
581 result[name] = (
582 value[0] if isinstance(value, list) and len(value) == 1 else value
583 )
584 return result
585
586 @classmethod
587 def from_jcal_property(cls, jcal_property: list):
588 """Create the parameters for a jCal property.
589
590 Parameters:
591 jcal_property (list): The jCal property [name, params, value, ...]
592 default_value (str, optional): The default value of the property.
593 If this is given, the default value will not be set.
594 """
595 if not isinstance(jcal_property, list) or len(jcal_property) < 4:
596 raise JCalParsingError(
597 "The property must be a list with at least 4 items.", cls
598 )
599 jcal_params = jcal_property[1]
600 with JCalParsingError.reraise_with_path_added(1):
601 self = cls.from_jcal(jcal_params)
602 if self.is_utc():
603 del self.tzid # we do not want this parameter
604 return self
605
606 @classmethod
607 def from_xcal(cls, element: Element | ElementAdapter) -> Parameters:
608 """Parse xCal from :rfc:`6321`.
609
610 Parameters:
611 element: The xCal element to parse.
612
613 Raises:
614 ~error.XCalParsingError: If the provided xCal is invalid.
615
616 Returns:
617 :Parameters: The parsed parameters.
618
619 Example:
620
621 This parses the parameters from an xCal string.
622
623 .. code-block:: pycon
624
625 >>> from icalendar import Parameters
626 >>> from xml.etree.ElementTree import fromstring
627 >>> xcal_string = '''
628 ... <parameters>
629 ... <language>
630 ... <text>en-US</text>
631 ... </language>
632 ... </parameters>
633 ... '''
634 >>> xml_element = fromstring(xcal_string)
635 >>> parameters = Parameters.from_xcal(xml_element)
636 >>> parameters['language'] == 'en-US'
637 True
638
639 """
640 from icalendar.parser.xcal.parameters import XCalParametersParser
641
642 parser = XCalParametersParser(element)
643 return parser.parse_parameters()
644
645 def to_xcal(self, element: Element) -> None:
646 """Add the xCal representation of the parameters according to :rfc:`6321`.
647
648 No parameters are added when they are empty or parameters are already present
649 in the element.
650
651 Parameters:
652 element: The parameters are added to this element as first child.
653 """
654 if not self or (len(element) >= 1 and element[0].tag == "parameters"):
655 return # exit quickly
656 from icalendar.prop.factory import TypesFactory
657
658 result = Element("parameters")
659 factory = TypesFactory.instance()
660 for key in sorted(self):
661 if key == "VALUE":
662 continue
663 value_factory = factory.for_property(key)
664 param_element = Element(key.lower())
665 for value in self.get_multiple(key):
666 # value is expected to be str or a subclass of str
667 if hasattr(value, "to_xcal"):
668 # str subclass
669 value.to_xcal(param_element)
670 else:
671 parsed_value = value_factory.from_ical(value)
672 if hasattr(parsed_value, "to_xcal"):
673 # property type
674 parsed_value.to_xcal(param_element) # type: ignore # noqa: PGH003
675 else:
676 # native type
677 v_prop = value_factory(parsed_value) # type: ignore # noqa: PGH003
678 v_prop.to_xcal(param_element)
679 result.append(param_element)
680 if len(result) > 0:
681 # Parameters always go first.
682 element.insert(0, result)
683
684 def get_multiple(self, key: str) -> list:
685 """Get mulitple values as a list.
686
687 .. note::
688
689 Do not modify the list. This is only for iteration.
690 """
691 result = self.get(key, [])
692 if not isinstance(result, list):
693 return [result]
694 return result
695
696
697RFC_6868_UNESCAPE_REGEX = re.compile(r"\^\^|\^n|\^'")
698
699
700def _rfc_6868_unescape(param_value: str) -> str:
701 """Take care of :rfc:`6868` unescaping.
702
703 - ^^ -> ^
704 - ^n -> system specific newline
705 - ^' -> "
706 - ^ with others stay intact
707 """
708 replacements = {
709 "^^": "^",
710 "^n": os.linesep,
711 "^'": '"',
712 }
713 return RFC_6868_UNESCAPE_REGEX.sub(
714 lambda m: replacements.get(m.group(0), m.group(0)), param_value
715 )
716
717
718RFC_6868_ESCAPE_REGEX = re.compile(r'\^|\r\n|\r|\n|"')
719
720
721def _rfc_6868_escape(param_value: str) -> str:
722 """Take care of :rfc:`6868` escaping.
723
724 - ^ -> ^^
725 - " -> ^'
726 - newline -> ^n
727 """
728 replacements = {
729 "^": "^^",
730 "\n": "^n",
731 "\r": "^n",
732 "\r\n": "^n",
733 '"': "^'",
734 }
735 return RFC_6868_ESCAPE_REGEX.sub(
736 lambda m: replacements.get(m.group(0), m.group(0)), param_value
737 )
738
739
740rfc_6868_escape = deprecate_for_version_9(_rfc_6868_escape)
741rfc_6868_unescape = deprecate_for_version_9(_rfc_6868_unescape)
742
743
744__all__ = [
745 "Parameters",
746 "dquote",
747 "param_value",
748 "q_join",
749 "q_split",
750 "validate_param_value",
751]