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 deprecate_for_version_8
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, 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 """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.
102
103 Parameters:
104 val: The parameter value to quote.
105 always_quote: If ``True``, always enclose in quotes regardless of content.
106 Defaults to ``False`` (only quote when necessary).
107
108 Returns:
109 The value, enclosed in double quotes if needed or requested.
110 """
111 # a double-quote character is forbidden to appear in a parameter value
112 # so replace it with a single-quote character
113 val = val.replace('"', "'")
114 if QUOTABLE.search(val) or always_quote:
115 return f'"{val}"'
116 return val
117
118
119# parsing helper
120def q_split(st: str, sep: str = ",", maxsplit: int = -1) -> list[str]:
121 """Split a string on a separator, respecting double quotes.
122
123 Splits the string on the separator character, but ignores separators that
124 appear inside double-quoted sections. This is needed for parsing parameter
125 values that may contain quoted strings.
126
127 Parameters:
128 st: The string to split.
129 sep: The separator character. Defaults to ``,``.
130 maxsplit: Maximum number of splits to perform. If ``-1`` (default),
131 then perform all possible splits.
132
133 Returns:
134 The split string parts.
135
136 Examples:
137 .. code-block:: pycon
138
139 >>> from icalendar.parser import q_split
140 >>> q_split('a,b,c')
141 ['a', 'b', 'c']
142 >>> q_split('a,"b,c",d')
143 ['a', '"b,c"', 'd']
144 >>> q_split('a;b;c', sep=';')
145 ['a', 'b', 'c']
146 """
147 if maxsplit == 0:
148 return [st]
149
150 result = []
151 cursor = 0
152 length = len(st)
153 inquote = 0
154 splits = 0
155 for i, ch in enumerate(st):
156 if ch == '"':
157 inquote = not inquote
158 if not inquote and ch == sep:
159 result.append(st[cursor:i])
160 cursor = i + 1
161 splits += 1
162 if i + 1 == length or splits == maxsplit:
163 result.append(st[cursor:])
164 break
165 return result
166
167
168def q_join(lst: Sequence[str], sep: str = ",", always_quote: bool = False) -> str:
169 """Join a list with a separator, quoting items as needed.
170
171 Joins list items with the separator, applying :func:`dquote` to each item
172 to add double quotes when they contain special characters.
173
174 Parameters:
175 lst: The list of items to join.
176 sep: The separator to use. Defaults to ``,``.
177 always_quote: If ``True``, always quote all items. Defaults to ``False``
178 (only quote when necessary).
179
180 Returns:
181 The joined string with items quoted as needed.
182
183 Examples:
184 .. code-block:: pycon
185
186 >>> from icalendar.parser import q_join
187 >>> q_join(['a', 'b', 'c'])
188 'a,b,c'
189 >>> q_join(['plain', 'has,comma'])
190 'plain,"has,comma"'
191 """
192 return sep.join(dquote(itm, always_quote=always_quote) for itm in lst)
193
194
195def _single_string_parameter(func: Callable | None = None, upper: bool = False):
196 """Create a parameter getter/setter for a single string parameter.
197
198 Parameters:
199 upper: Convert the value to uppercase
200 func: The function to decorate.
201
202 Returns:
203 The property for the parameter or a decorator for the parameter
204 if func is ``None``.
205 """
206
207 def decorator(func: Callable) -> property:
208 name = func.__name__
209
210 @functools.wraps(func)
211 def fget(self: Parameters):
212 """Get the value."""
213 value = self.get(name)
214 if value is not None and upper:
215 value = value.upper()
216 return value
217
218 def fset(self: Parameters, value: str | None):
219 """Set the value"""
220 if value is None:
221 fdel(self)
222 else:
223 if upper:
224 value = value.upper()
225 self[name] = value
226
227 def fdel(self: Parameters):
228 """Delete the value."""
229 self.pop(name, None)
230
231 return property(fget, fset, fdel, doc=func.__doc__)
232
233 if func is None:
234 return decorator
235 return decorator(func)
236
237
238single_string_parameter = deprecate_for_version_8(_single_string_parameter)
239
240
241class Parameters(CaselessDict):
242 """Parser and generator of Property parameter strings.
243
244 It knows nothing of datatypes.
245 Its main concern is textual structure.
246
247 Examples:
248
249 Modify parameters:
250
251 .. code-block:: pycon
252
253 >>> from icalendar import Parameters
254 >>> params = Parameters()
255 >>> params['VALUE'] = 'TEXT'
256 >>> params.value
257 'TEXT'
258 >>> params
259 Parameters({'VALUE': 'TEXT'})
260
261 Create new parameters:
262
263 .. code-block:: pycon
264
265 >>> params = Parameters(value="BINARY")
266 >>> params.value
267 'BINARY'
268
269 Set a default:
270
271 .. code-block:: pycon
272
273 >>> params = Parameters(value="BINARY", default_value="TEXT")
274 >>> params
275 Parameters({'VALUE': 'BINARY'})
276
277 """
278
279 def __init__(self, *args: Any, **kwargs: Any) -> None:
280 """Create new parameters."""
281 if args and args[0] is None:
282 # allow passing None
283 args = args[1:]
284 defaults = {
285 key[8:]: kwargs.pop(key)
286 for key in list(kwargs.keys())
287 if key.lower().startswith("default_")
288 }
289 super().__init__(*args, **kwargs)
290 for key, value in defaults.items():
291 self.setdefault(key, value)
292
293 # The following paremeters must always be enclosed in double quotes
294 always_quoted = (
295 "ALTREP",
296 "DELEGATED-FROM",
297 "DELEGATED-TO",
298 "DIR",
299 "MEMBER",
300 "SENT-BY",
301 # Part of X-APPLE-STRUCTURED-LOCATION
302 "X-ADDRESS",
303 "X-TITLE",
304 # RFC 9253
305 "LINKREL",
306 )
307 # this is quoted should one of the values be present
308 quote_also = {
309 # This is escaped in the RFC
310 "CN": " '",
311 }
312
313 def params(self):
314 """In RFC 5545 keys are called parameters, so this is to be consitent
315 with the naming conventions.
316 """
317 return self.keys()
318
319 def to_ical(self, sorted: bool = True): # noqa: A002
320 """Returns an :rfc:`5545` representation of the parameters.
321
322 Parameters:
323 sorted (bool): Sort the parameters before encoding.
324 exclude_utc (bool): Exclude TZID if it is set to ``"UTC"``
325 """
326 result = []
327 items = list(self.items())
328 if sorted:
329 items.sort()
330
331 for key, value in items:
332 if key == "TZID" and value == "UTC":
333 # The "TZID" property parameter MUST NOT be applied to DATE-TIME
334 # properties whose time values are specified in UTC.
335 continue
336 upper_key = key.upper()
337 check_quoteable_characters = self.quote_also.get(key.upper())
338 always_quote = upper_key in self.always_quoted or (
339 check_quoteable_characters
340 and any(c in value for c in check_quoteable_characters)
341 )
342 quoted_value = param_value(value, always_quote=always_quote)
343 if isinstance(quoted_value, str):
344 quoted_value = quoted_value.encode(DEFAULT_ENCODING)
345 # CaselessDict keys are always unicode
346 result.append(upper_key.encode(DEFAULT_ENCODING) + b"=" + quoted_value)
347 return b";".join(result)
348
349 @classmethod
350 def from_ical(cls, st, strict=False):
351 """Parses the parameter format from ical text format."""
352
353 # parse into strings
354 result = cls()
355 for param in q_split(st, ";"):
356 try:
357 key, val = q_split(param, "=", maxsplit=1)
358 validate_token(key)
359 # Property parameter values that are not in quoted
360 # strings are case insensitive.
361 string_values = []
362 for v in q_split(val, ","):
363 if v.startswith('"') and v.endswith('"'):
364 v2 = v.strip('"')
365 validate_param_value(v2, quoted=True)
366 string_values.append(rfc_6868_unescape(v2))
367 else:
368 validate_param_value(v, quoted=False)
369 if strict:
370 string_values.append(rfc_6868_unescape(v.upper()))
371 else:
372 string_values.append(rfc_6868_unescape(v))
373
374 if not string_values:
375 string_values = [val]
376 result[key] = (
377 string_values[0] if len(string_values) == 1 else string_values
378 )
379 except ValueError as exc: # noqa: PERF203
380 raise ValueError(
381 f"{param!r} is not a valid parameter string: {exc}"
382 ) from exc
383 return result
384
385 @_single_string_parameter(upper=True)
386 def value(self) -> VALUE | str | None:
387 """The VALUE parameter from :rfc:`5545`.
388
389 Description:
390 This parameter specifies the value type and format of
391 the property value. The property values MUST be of a single value
392 type. For example, a "RDATE" property cannot have a combination
393 of DATE-TIME and TIME value types.
394
395 If the property's value is the default value type, then this
396 parameter need not be specified. However, if the property's
397 default value type is overridden by some other allowable value
398 type, then this parameter MUST be specified.
399
400 Applications MUST preserve the value data for x-name and iana-
401 token values that they don't recognize without attempting to
402 interpret or parse the value data.
403
404 For convenience, using this property, the value will be converted to
405 an uppercase string.
406
407 .. code-block:: pycon
408
409 >>> from icalendar import Parameters
410 >>> params = Parameters()
411 >>> params.value = "unknown"
412 >>> params
413 Parameters({'VALUE': 'UNKNOWN'})
414
415 """
416
417 def _parameter_value_to_jcal(
418 self, value: str | float | list | VPROPERTY
419 ) -> str | int | float | list[str] | list[int] | list[float]:
420 """Convert a parameter value to jCal format.
421
422 Parameters:
423 value: The parameter value
424
425 Returns:
426 The jCal representation of the parameter value
427 """
428 if isinstance(value, list):
429 return [self._parameter_value_to_jcal(v) for v in value]
430 if hasattr(value, "to_jcal"):
431 # proprty values respond to this
432 jcal = value.to_jcal()
433 # we only need the value part
434 if len(jcal) == 4:
435 return jcal[3]
436 return jcal[3:]
437 for t in (int, float, str):
438 if isinstance(value, t):
439 return t(value)
440 raise TypeError(
441 "Unsupported parameter value type for jCal conversion: "
442 f"{type(value)} {value!r}"
443 )
444
445 def to_jcal(self, exclude_utc=False) -> dict[str, str]:
446 """Return the jCal representation of the parameters.
447
448 Parameters:
449 exclude_utc (bool): Exclude the TZID parameter if it is UTC
450 """
451 jcal = {
452 k.lower(): self._parameter_value_to_jcal(v)
453 for k, v in self.items()
454 if k.lower() != "value"
455 }
456 if exclude_utc and jcal.get("tzid") == "UTC":
457 del jcal["tzid"]
458 return jcal
459
460 @_single_string_parameter
461 def tzid(self) -> str | None:
462 """The TZID parameter from :rfc:`5545`."""
463
464 def is_utc(self) -> bool:
465 """Whether the TZID parameter is UTC."""
466 return self.tzid == "UTC"
467
468 def update_tzid_from(
469 self, dt: datetime | time | tuple[datetime, Any] | Any
470 ) -> None:
471 """Update the TZID parameter from a datetime object.
472
473 This sets the TZID parameter or deletes it according to the datetime.
474 :rfc:`5545#section-3.2.19` prohibits TZID on UTC datetimes,
475 which use the ``Z`` suffix instead.
476 """
477 if isinstance(dt, tuple) and len(dt) >= 1:
478 dt = dt[0]
479 if isinstance(dt, (datetime, time)):
480 tzid = tzid_from_dt(dt)
481 # UTC uses Z suffix and does not appear as TZID parameter
482 if tzid == "UTC":
483 del self.tzid
484 else:
485 self.tzid = tzid
486
487 @classmethod
488 def from_jcal(cls, jcal: dict[str : str | list[str]]):
489 """Parse jCal parameters."""
490 if not isinstance(jcal, dict):
491 raise JCalParsingError("The parameters must be a mapping.", cls)
492 result = cls()
493 for name, value in jcal.items():
494 if not isinstance(name, str):
495 raise JCalParsingError(
496 "All parameter names must be strings.", cls, value=name
497 )
498 JCalParsingError.validate_jcal_token(name, "parameter name", cls)
499 if not (
500 (
501 isinstance(value, list)
502 and all(isinstance(v, (str, int, float)) for v in value)
503 and value
504 )
505 or isinstance(value, (str, int, float))
506 ):
507 raise JCalParsingError(
508 "Parameter values must be a string, integer or "
509 "float or a list of those.",
510 cls,
511 name,
512 value=value,
513 )
514 result[name] = (
515 value[0] if isinstance(value, list) and len(value) == 1 else value
516 )
517 return result
518
519 @classmethod
520 def from_jcal_property(cls, jcal_property: list):
521 """Create the parameters for a jCal property.
522
523 Parameters:
524 jcal_property (list): The jCal property [name, params, value, ...]
525 default_value (str, optional): The default value of the property.
526 If this is given, the default value will not be set.
527 """
528 if not isinstance(jcal_property, list) or len(jcal_property) < 4:
529 raise JCalParsingError(
530 "The property must be a list with at least 4 items.", cls
531 )
532 jcal_params = jcal_property[1]
533 with JCalParsingError.reraise_with_path_added(1):
534 self = cls.from_jcal(jcal_params)
535 if self.is_utc():
536 del self.tzid # we do not want this parameter
537 return self
538
539 @classmethod
540 def from_xcal(cls, element: Element | ElementAdapter) -> Parameters:
541 """Parse xCal from :rfc:`6321`.
542
543 Parameters:
544 element: The xCal element to parse.
545
546 Raises:
547 ~error.XCalParsingError: If the provided xCal is invalid.
548
549 Returns:
550 :Parameters: The parsed parameters.
551
552 Example:
553
554 This parses the parameters from an xCal string.
555
556 .. code-block:: pycon
557
558 >>> from icalendar import Parameters
559 >>> from xml.etree.ElementTree import fromstring
560 >>> xcal_string = '''
561 ... <parameters>
562 ... <language>
563 ... <text>en-US</text>
564 ... </language>
565 ... </parameters>
566 ... '''
567 >>> xml_element = fromstring(xcal_string)
568 >>> parameters = Parameters.from_xcal(xml_element)
569 >>> parameters['language'] == 'en-US'
570 True
571
572 """
573 from icalendar.parser.xcal.parameters import XCalParametersParser
574
575 parser = XCalParametersParser(element)
576 return parser.parse_parameters()
577
578 def to_xcal(self, element: Element) -> None:
579 """Add the xCal representation of the parameters according to :rfc:`6321`.
580
581 No parameters are added when they are empty or parameters are already present
582 in the element.
583
584 Parameters:
585 element: The parameters are added to this element as first child.
586 """
587 if not self or (len(element) >= 1 and element[0].tag == "parameters"):
588 return # exit quickly
589 from icalendar.prop.factory import TypesFactory
590
591 result = Element("parameters")
592 factory = TypesFactory.instance()
593 for key in sorted(self):
594 if key == "VALUE":
595 continue
596 value_factory = factory.for_property(key)
597 param_element = Element(key.lower())
598 for value in self.get_multiple(key):
599 # value is expected to be str or a subclass of str
600 if hasattr(value, "to_xcal"):
601 # str subclass
602 value.to_xcal(param_element)
603 else:
604 parsed_value = value_factory.from_ical(value)
605 if hasattr(parsed_value, "to_xcal"):
606 # property type
607 parsed_value.to_xcal(param_element) # type: ignore # noqa: PGH003
608 else:
609 # native type
610 v_prop = value_factory(parsed_value) # type: ignore # noqa: PGH003
611 v_prop.to_xcal(param_element)
612 result.append(param_element)
613 if len(result) > 0:
614 # Parameters always go first.
615 element.insert(0, result)
616
617 def get_multiple(self, key: str) -> list:
618 """Get mulitple values as a list.
619
620 .. note::
621
622 Do not modify the list. This is only for iteration.
623 """
624 result = self.get(key, [])
625 if not isinstance(result, list):
626 return [result]
627 return result
628
629
630RFC_6868_UNESCAPE_REGEX = re.compile(r"\^\^|\^n|\^'")
631
632
633def rfc_6868_unescape(param_value: str) -> str:
634 """Take care of :rfc:`6868` unescaping.
635
636 - ^^ -> ^
637 - ^n -> system specific newline
638 - ^' -> "
639 - ^ with others stay intact
640 """
641 replacements = {
642 "^^": "^",
643 "^n": os.linesep,
644 "^'": '"',
645 }
646 return RFC_6868_UNESCAPE_REGEX.sub(
647 lambda m: replacements.get(m.group(0), m.group(0)), param_value
648 )
649
650
651RFC_6868_ESCAPE_REGEX = re.compile(r'\^|\r\n|\r|\n|"')
652
653
654def rfc_6868_escape(param_value: str) -> str:
655 """Take care of :rfc:`6868` escaping.
656
657 - ^ -> ^^
658 - " -> ^'
659 - newline -> ^n
660 """
661 replacements = {
662 "^": "^^",
663 "\n": "^n",
664 "\r": "^n",
665 "\r\n": "^n",
666 '"': "^'",
667 }
668 return RFC_6868_ESCAPE_REGEX.sub(
669 lambda m: replacements.get(m.group(0), m.group(0)), param_value
670 )
671
672
673__all__ = [
674 "Parameters",
675 "dquote",
676 "param_value",
677 "q_join",
678 "q_split",
679 "rfc_6868_escape",
680 "rfc_6868_unescape",
681 "validate_param_value",
682]