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