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

187 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.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]