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
10
11from icalendar.caselessdict import CaselessDict
12from icalendar.compatibility import deprecate_for_version_8
13from icalendar.error import JCalParsingError
14from icalendar.parser.string import validate_token
15from icalendar.parser_tools import (
16 DEFAULT_ENCODING,
17 SEQUENCE_TYPES,
18)
19from icalendar.timezone.tzid import tzid_from_dt
20
21if TYPE_CHECKING:
22 from collections.abc import Callable, Sequence
23
24 from icalendar.enums import VALUE
25 from icalendar.prop import VPROPERTY
26
27
28class HasToIcal(Protocol):
29 """Protocol for objects with a to_ical method."""
30
31 def to_ical(self) -> bytes:
32 """Convert to iCalendar format."""
33 ...
34
35
36def param_value(
37 value: Sequence[str] | str | HasToIcal, always_quote: bool = False
38) -> str:
39 """Convert a parameter value to its iCalendar representation.
40
41 Applies :rfc:`6868` escaping and optionally quotes the value according
42 to :rfc:`5545` parameter value formatting rules.
43
44 Parameters:
45 value: The parameter value to convert. Can be a sequence, string, or
46 object with a ``to_ical()`` method.
47 always_quote: If ``True``, always enclose the value in double quotes.
48 Defaults to ``False`` (only quote when necessary).
49
50 Returns:
51 The formatted parameter value, escaped and quoted as needed.
52 """
53 if isinstance(value, SEQUENCE_TYPES):
54 return q_join(map(rfc_6868_escape, value), always_quote=always_quote)
55 if isinstance(value, str):
56 return dquote(rfc_6868_escape(value), always_quote=always_quote)
57 return dquote(rfc_6868_escape(value.to_ical().decode(DEFAULT_ENCODING)))
58
59
60# Could be improved
61
62
63UNSAFE_CHAR = re.compile('[\x00-\x08\x0a-\x1f\x7f",:;]')
64QUNSAFE_CHAR = re.compile('[\x00-\x08\x0a-\x1f\x7f"]')
65
66
67def validate_param_value(value: str, quoted: bool = True) -> None:
68 """Validate a parameter value for unsafe characters.
69
70 Checks parameter values for characters that are not allowed according to
71 :rfc:`5545`. Uses different validation rules for quoted and unquoted values.
72
73 Parameters:
74 value: The parameter value to validate.
75 quoted: If ``True``, validate as a quoted value (allows more characters).
76 If ``False``, validate as an unquoted value (stricter).
77 Defaults to ``True``.
78
79 Raises:
80 ValueError: If the value contains unsafe characters for its quote state.
81 """
82 validator = QUNSAFE_CHAR if quoted else UNSAFE_CHAR
83 if validator.findall(value):
84 raise ValueError(value)
85
86
87# chars presence of which in parameter value will be cause the value
88# to be enclosed in double-quotes
89QUOTABLE = re.compile("[,;:’]") # noqa: RUF001
90
91
92def dquote(val: str, always_quote: bool = False) -> str:
93 """Enclose parameter values in double quotes when needed.
94
95 Parameter values containing special characters ``,``, ``;``,
96 ``:`` or ``'`` must be enclosed
97 in double quotes according to :rfc:`5545`. Double-quote characters in the
98 value are replaced with single quotes since they're forbidden in parameter
99 values.
100
101 Parameters:
102 val: The parameter value to quote.
103 always_quote: If ``True``, always enclose in quotes regardless of content.
104 Defaults to ``False`` (only quote when necessary).
105
106 Returns:
107 The value, enclosed in double quotes if needed or requested.
108 """
109 # a double-quote character is forbidden to appear in a parameter value
110 # so replace it with a single-quote character
111 val = val.replace('"', "'")
112 if QUOTABLE.search(val) or always_quote:
113 return f'"{val}"'
114 return val
115
116
117# parsing helper
118def q_split(st: str, sep: str = ",", maxsplit: int = -1) -> list[str]:
119 """Split a string on a separator, respecting double quotes.
120
121 Splits the string on the separator character, but ignores separators that
122 appear inside double-quoted sections. This is needed for parsing parameter
123 values that may contain quoted strings.
124
125 Parameters:
126 st: The string to split.
127 sep: The separator character. Defaults to ``,``.
128 maxsplit: Maximum number of splits to perform. If ``-1`` (default),
129 then perform all possible splits.
130
131 Returns:
132 The split string parts.
133
134 Examples:
135 .. code-block:: pycon
136
137 >>> from icalendar.parser import q_split
138 >>> q_split('a,b,c')
139 ['a', 'b', 'c']
140 >>> q_split('a,"b,c",d')
141 ['a', '"b,c"', 'd']
142 >>> q_split('a;b;c', sep=';')
143 ['a', 'b', 'c']
144 """
145 if maxsplit == 0:
146 return [st]
147
148 result = []
149 cursor = 0
150 length = len(st)
151 inquote = 0
152 splits = 0
153 for i, ch in enumerate(st):
154 if ch == '"':
155 inquote = not inquote
156 if not inquote and ch == sep:
157 result.append(st[cursor:i])
158 cursor = i + 1
159 splits += 1
160 if i + 1 == length or splits == maxsplit:
161 result.append(st[cursor:])
162 break
163 return result
164
165
166def q_join(lst: Sequence[str], sep: str = ",", always_quote: bool = False) -> str:
167 """Join a list with a separator, quoting items as needed.
168
169 Joins list items with the separator, applying :func:`dquote` to each item
170 to add double quotes when they contain special characters.
171
172 Parameters:
173 lst: The list of items to join.
174 sep: The separator to use. Defaults to ``,``.
175 always_quote: If ``True``, always quote all items. Defaults to ``False``
176 (only quote when necessary).
177
178 Returns:
179 The joined string with items quoted as needed.
180
181 Examples:
182 .. code-block:: pycon
183
184 >>> from icalendar.parser import q_join
185 >>> q_join(['a', 'b', 'c'])
186 'a,b,c'
187 >>> q_join(['plain', 'has,comma'])
188 'plain,"has,comma"'
189 """
190 return sep.join(dquote(itm, always_quote=always_quote) for itm in lst)
191
192
193def _single_string_parameter(func: Callable | None = None, upper=False):
194 """Create a parameter getter/setter for a single string parameter.
195
196 Parameters:
197 upper: Convert the value to uppercase
198 func: The function to decorate.
199
200 Returns:
201 The property for the parameter or a decorator for the parameter
202 if func is ``None``.
203 """
204
205 def decorator(func):
206 name = func.__name__
207
208 @functools.wraps(func)
209 def fget(self: Parameters):
210 """Get the value."""
211 value = self.get(name)
212 if value is not None and upper:
213 value = value.upper()
214 return value
215
216 def fset(self: Parameters, value: str | None):
217 """Set the value"""
218 if value is None:
219 fdel(self)
220 else:
221 if upper:
222 value = value.upper()
223 self[name] = value
224
225 def fdel(self: Parameters):
226 """Delete the value."""
227 self.pop(name, None)
228
229 return property(fget, fset, fdel, doc=func.__doc__)
230
231 if func is None:
232 return decorator
233 return decorator(func)
234
235
236single_string_parameter = deprecate_for_version_8(_single_string_parameter)
237
238
239class Parameters(CaselessDict):
240 """Parser and generator of Property parameter strings.
241
242 It knows nothing of datatypes.
243 Its main concern is textual structure.
244
245 Examples:
246
247 Modify parameters:
248
249 .. code-block:: pycon
250
251 >>> from icalendar import Parameters
252 >>> params = Parameters()
253 >>> params['VALUE'] = 'TEXT'
254 >>> params.value
255 'TEXT'
256 >>> params
257 Parameters({'VALUE': 'TEXT'})
258
259 Create new parameters:
260
261 .. code-block:: pycon
262
263 >>> params = Parameters(value="BINARY")
264 >>> params.value
265 'BINARY'
266
267 Set a default:
268
269 .. code-block:: pycon
270
271 >>> params = Parameters(value="BINARY", default_value="TEXT")
272 >>> params
273 Parameters({'VALUE': 'BINARY'})
274
275 """
276
277 def __init__(self, *args: Any, **kwargs: Any) -> None:
278 """Create new parameters."""
279 if args and args[0] is None:
280 # allow passing None
281 args = args[1:]
282 defaults = {
283 key[8:]: kwargs.pop(key)
284 for key in list(kwargs.keys())
285 if key.lower().startswith("default_")
286 }
287 super().__init__(*args, **kwargs)
288 for key, value in defaults.items():
289 self.setdefault(key, value)
290
291 # The following paremeters must always be enclosed in double quotes
292 always_quoted = (
293 "ALTREP",
294 "DELEGATED-FROM",
295 "DELEGATED-TO",
296 "DIR",
297 "MEMBER",
298 "SENT-BY",
299 # Part of X-APPLE-STRUCTURED-LOCATION
300 "X-ADDRESS",
301 "X-TITLE",
302 # RFC 9253
303 "LINKREL",
304 )
305 # this is quoted should one of the values be present
306 quote_also = {
307 # This is escaped in the RFC
308 "CN": " '",
309 }
310
311 def params(self):
312 """In RFC 5545 keys are called parameters, so this is to be consitent
313 with the naming conventions.
314 """
315 return self.keys()
316
317 def to_ical(self, sorted: bool = True): # noqa: A002
318 """Returns an :rfc:`5545` representation of the parameters.
319
320 Parameters:
321 sorted (bool): Sort the parameters before encoding.
322 exclude_utc (bool): Exclude TZID if it is set to ``"UTC"``
323 """
324 result = []
325 items = list(self.items())
326 if sorted:
327 items.sort()
328
329 for key, value in items:
330 if key == "TZID" and value == "UTC":
331 # The "TZID" property parameter MUST NOT be applied to DATE-TIME
332 # properties whose time values are specified in UTC.
333 continue
334 upper_key = key.upper()
335 check_quoteable_characters = self.quote_also.get(key.upper())
336 always_quote = upper_key in self.always_quoted or (
337 check_quoteable_characters
338 and any(c in value for c in check_quoteable_characters)
339 )
340 quoted_value = param_value(value, always_quote=always_quote)
341 if isinstance(quoted_value, str):
342 quoted_value = quoted_value.encode(DEFAULT_ENCODING)
343 # CaselessDict keys are always unicode
344 result.append(upper_key.encode(DEFAULT_ENCODING) + b"=" + quoted_value)
345 return b";".join(result)
346
347 @classmethod
348 def from_ical(cls, st, strict=False):
349 """Parses the parameter format from ical text format."""
350
351 # parse into strings
352 result = cls()
353 for param in q_split(st, ";"):
354 try:
355 key, val = q_split(param, "=", maxsplit=1)
356 validate_token(key)
357 # Property parameter values that are not in quoted
358 # strings are case insensitive.
359 vals = []
360 for v in q_split(val, ","):
361 if v.startswith('"') and v.endswith('"'):
362 v2 = v.strip('"')
363 validate_param_value(v2, quoted=True)
364 vals.append(rfc_6868_unescape(v2))
365 else:
366 validate_param_value(v, quoted=False)
367 if strict:
368 vals.append(rfc_6868_unescape(v.upper()))
369 else:
370 vals.append(rfc_6868_unescape(v))
371 if not vals:
372 result[key] = val
373 elif len(vals) == 1:
374 result[key] = vals[0]
375 else:
376 result[key] = vals
377 except ValueError as exc: # noqa: PERF203
378 raise ValueError(
379 f"{param!r} is not a valid parameter string: {exc}"
380 ) from exc
381 return result
382
383 @_single_string_parameter(upper=True)
384 def value(self) -> VALUE | str | None:
385 """The VALUE parameter from :rfc:`5545`.
386
387 Description:
388 This parameter specifies the value type and format of
389 the property value. The property values MUST be of a single value
390 type. For example, a "RDATE" property cannot have a combination
391 of DATE-TIME and TIME value types.
392
393 If the property's value is the default value type, then this
394 parameter need not be specified. However, if the property's
395 default value type is overridden by some other allowable value
396 type, then this parameter MUST be specified.
397
398 Applications MUST preserve the value data for x-name and iana-
399 token values that they don't recognize without attempting to
400 interpret or parse the value data.
401
402 For convenience, using this property, the value will be converted to
403 an uppercase string.
404
405 .. code-block:: pycon
406
407 >>> from icalendar import Parameters
408 >>> params = Parameters()
409 >>> params.value = "unknown"
410 >>> params
411 Parameters({'VALUE': 'UNKNOWN'})
412
413 """
414
415 def _parameter_value_to_jcal(
416 self, value: str | float | list | VPROPERTY
417 ) -> str | int | float | list[str] | list[int] | list[float]:
418 """Convert a parameter value to jCal format.
419
420 Parameters:
421 value: The parameter value
422
423 Returns:
424 The jCal representation of the parameter value
425 """
426 if isinstance(value, list):
427 return [self._parameter_value_to_jcal(v) for v in value]
428 if hasattr(value, "to_jcal"):
429 # proprty values respond to this
430 jcal = value.to_jcal()
431 # we only need the value part
432 if len(jcal) == 4:
433 return jcal[3]
434 return jcal[3:]
435 for t in (int, float, str):
436 if isinstance(value, t):
437 return t(value)
438 raise TypeError(
439 "Unsupported parameter value type for jCal conversion: "
440 f"{type(value)} {value!r}"
441 )
442
443 def to_jcal(self, exclude_utc=False) -> dict[str, str]:
444 """Return the jCal representation of the parameters.
445
446 Parameters:
447 exclude_utc (bool): Exclude the TZID parameter if it is UTC
448 """
449 jcal = {
450 k.lower(): self._parameter_value_to_jcal(v)
451 for k, v in self.items()
452 if k.lower() != "value"
453 }
454 if exclude_utc and jcal.get("tzid") == "UTC":
455 del jcal["tzid"]
456 return jcal
457
458 @_single_string_parameter
459 def tzid(self) -> str | None:
460 """The TZID parameter from :rfc:`5545`."""
461
462 def is_utc(self) -> bool:
463 """Whether the TZID parameter is UTC."""
464 return self.tzid == "UTC"
465
466 def update_tzid_from(self, dt: datetime | time | Any) -> None:
467 """Update the TZID parameter from a datetime object.
468
469 This sets the TZID parameter or deletes it according to the datetime.
470 :rfc:`5545#section-3.2.19` prohibits TZID on UTC datetimes,
471 which use the ``Z`` suffix instead.
472 """
473 if isinstance(dt, (datetime, time)):
474 tzid = tzid_from_dt(dt)
475 if tzid != "UTC":
476 # UTC uses Z suffix and does not appear as TZID parameter
477 self.tzid = tzid
478
479 @classmethod
480 def from_jcal(cls, jcal: dict[str : str | list[str]]):
481 """Parse jCal parameters."""
482 if not isinstance(jcal, dict):
483 raise JCalParsingError("The parameters must be a mapping.", cls)
484 for name, value in jcal.items():
485 if not isinstance(name, str):
486 raise JCalParsingError(
487 "All parameter names must be strings.", cls, value=name
488 )
489 JCalParsingError.validate_jcal_token(name, "parameter name", cls)
490 if not (
491 (
492 isinstance(value, list)
493 and all(isinstance(v, (str, int, float)) for v in value)
494 and value
495 )
496 or isinstance(value, (str, int, float))
497 ):
498 raise JCalParsingError(
499 "Parameter values must be a string, integer or "
500 "float or a list of those.",
501 cls,
502 name,
503 value=value,
504 )
505 return cls(jcal)
506
507 @classmethod
508 def from_jcal_property(cls, jcal_property: list):
509 """Create the parameters for a jCal property.
510
511 Parameters:
512 jcal_property (list): The jCal property [name, params, value, ...]
513 default_value (str, optional): The default value of the property.
514 If this is given, the default value will not be set.
515 """
516 if not isinstance(jcal_property, list) or len(jcal_property) < 4:
517 raise JCalParsingError(
518 "The property must be a list with at least 4 items.", cls
519 )
520 jcal_params = jcal_property[1]
521 with JCalParsingError.reraise_with_path_added(1):
522 self = cls.from_jcal(jcal_params)
523 if self.is_utc():
524 del self.tzid # we do not want this parameter
525 return self
526
527
528RFC_6868_UNESCAPE_REGEX = re.compile(r"\^\^|\^n|\^'")
529
530
531def rfc_6868_unescape(param_value: str) -> str:
532 """Take care of :rfc:`6868` unescaping.
533
534 - ^^ -> ^
535 - ^n -> system specific newline
536 - ^' -> "
537 - ^ with others stay intact
538 """
539 replacements = {
540 "^^": "^",
541 "^n": os.linesep,
542 "^'": '"',
543 }
544 return RFC_6868_UNESCAPE_REGEX.sub(
545 lambda m: replacements.get(m.group(0), m.group(0)), param_value
546 )
547
548
549RFC_6868_ESCAPE_REGEX = re.compile(r'\^|\r\n|\r|\n|"')
550
551
552def rfc_6868_escape(param_value: str) -> str:
553 """Take care of :rfc:`6868` escaping.
554
555 - ^ -> ^^
556 - " -> ^'
557 - newline -> ^n
558 """
559 replacements = {
560 "^": "^^",
561 "\n": "^n",
562 "\r": "^n",
563 "\r\n": "^n",
564 '"': "^'",
565 }
566 return RFC_6868_ESCAPE_REGEX.sub(
567 lambda m: replacements.get(m.group(0), m.group(0)), param_value
568 )
569
570
571__all__ = [
572 "Parameters",
573 "dquote",
574 "param_value",
575 "q_join",
576 "q_split",
577 "rfc_6868_escape",
578 "rfc_6868_unescape",
579 "validate_param_value",
580]