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

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

192 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 

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]