Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/icalendar/parser/parameter.py: 68%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

229 statements  

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]