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

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

167 statements  

1"""parsing and generation of content lines""" 

2 

3import re 

4 

5from icalendar.parser.parameter import Parameters, closes_quoted_section 

6from icalendar.parser.property import unescape_backslash, unescape_list_or_string 

7from icalendar.parser.string import ( 

8 _escape_string, 

9 _foldline, 

10 _unescape_string, 

11 validate_token, 

12) 

13from icalendar.parser_tools import DEFAULT_ENCODING, ICAL_TYPE, to_unicode 

14 

15# Equivalent to the natural ``(\r?\n)+[ \t]`` but without its quadratic cost: 

16# the greedy run is re-tried at every line break of a long unfoldable block, so 

17# a megabyte of bare newlines takes seconds. The leading lookbehinds pin a match 

18# to the first line break of a run, making a failed attempt O(1) instead of a 

19# full rescan, while matching exactly the same strings. 

20UFOLD = re.compile(r"(?:(?<!\n)\r\n|(?<![\r\n])\n)(?:\r?\n)*[ \t]") 

21NEWLINE = re.compile(r"\r?\n") 

22 

23OWS = " \t" 

24# ``[ \t]*([;=])[ \t]*`` in one pass rescans a long whitespace run at every 

25# position. Splitting it into two anchored passes (leading then trailing) keeps 

26# the result identical but removes the quadratic blow-up. 

27OWS_BEFORE_DELIMITER_RE = re.compile(r"(?<![ \t])[ \t]+([;=])") 

28OWS_AFTER_DELIMITER_RE = re.compile(r"([;=])[ \t]+") 

29 

30 

31def _strip_ows_around_delimiters(st: str, delimiters: str = ";=") -> str: 

32 """Strip optional whitespace around delimiters outside of quoted sections, 

33 respecting backslash escapes so that escaped delimiters are not treated as 

34 separators. 

35 

36 This is a lenient parsing helper (used when strict=False) to support 

37 iCalendar content lines that contain extra whitespace around tokens. 

38 """ 

39 if not st: 

40 return st 

41 

42 # Fast path for the common case in non-strict mode: 

43 # no whitespace in the parameter section means there is nothing to normalize. 

44 if " " not in st and "\t" not in st: 

45 return st 

46 

47 # Fast regex-based path for simple parameter sections without quoting/escaping. 

48 if delimiters == ";=" and '"' not in st and "\\" not in st: 

49 st = OWS_BEFORE_DELIMITER_RE.sub(r"\1", st) 

50 return OWS_AFTER_DELIMITER_RE.sub(r"\1", st).strip() 

51 

52 out: list[str] = [] 

53 pending_ws: list[str] = [] 

54 in_quotes = False 

55 escaped = False 

56 # True only if the last appended char was a raw delimiter. 

57 last_was_delimiter = False 

58 

59 def flush_pending() -> None: 

60 nonlocal pending_ws 

61 if not pending_ws: 

62 return 

63 if not last_was_delimiter: 

64 out.extend(pending_ws) 

65 pending_ws.clear() 

66 

67 for i, ch in enumerate(st): 

68 # Handle escaped character (the backslash set escaped in previous iteration) 

69 if escaped: 

70 flush_pending() 

71 out.append(ch) 

72 escaped = False 

73 # a DQUOTE preceded by a literal backslash still closes the 

74 # quoted section when a delimiter (or the end) follows it — 

75 # the shape icalendar writes for values ending in a backslash 

76 if ch == '"' and in_quotes and closes_quoted_section(st, i): 

77 in_quotes = False 

78 last_was_delimiter = False 

79 continue 

80 

81 # Handle backslash to escape next character — outside quotes it 

82 # protects delimiters, inside quotes it protects the closing quote 

83 # (``\"``), so an escaped quote does not toggle the quote state. 

84 if ch == "\\": 

85 flush_pending() 

86 out.append(ch) 

87 escaped = True 

88 last_was_delimiter = False 

89 continue 

90 

91 # Handle quote toggling 

92 if ch == '"' and not escaped: 

93 in_quotes = not in_quotes 

94 flush_pending() 

95 out.append(ch) 

96 last_was_delimiter = False 

97 continue 

98 

99 # Whitespace outside quotes is buffered 

100 if not in_quotes and not escaped and ch in OWS: 

101 pending_ws.append(ch) 

102 continue 

103 

104 # Raw delimiter (unescaped and outside quotes) 

105 if not in_quotes and not escaped and ch in delimiters: 

106 pending_ws.clear() 

107 while out and out[-1] in OWS: 

108 out.pop() 

109 out.append(ch) 

110 last_was_delimiter = True 

111 continue 

112 

113 # Regular character 

114 flush_pending() 

115 out.append(ch) 

116 last_was_delimiter = False 

117 

118 if pending_ws and not last_was_delimiter: 

119 out.extend(pending_ws) 

120 

121 return "".join(out).strip() 

122 

123 

124class Contentline(str): 

125 r"""A content line is a string that can be folded and parsed into parts. 

126 

127 Raises: 

128 ValueError: If the value contains ``\n``. 

129 

130 """ 

131 

132 __slots__ = ("strict",) 

133 

134 def __new__(cls, value, strict=False, encoding=DEFAULT_ENCODING): 

135 value = to_unicode(value, encoding=encoding) 

136 if "\n" in value: 

137 raise ValueError( 

138 "Content line can not contain unescaped new line characters." 

139 ) 

140 self = super().__new__(cls, value) 

141 self.strict = strict 

142 return self 

143 

144 @classmethod 

145 def from_parts( 

146 cls, 

147 name: ICAL_TYPE, 

148 params: Parameters, 

149 values, 

150 sorted: bool = True, # noqa: A002 

151 ): 

152 r"""Turn a parts into a content line. 

153 

154 Raises: 

155 TypeError: If ``params`` is not type ``Parameters``. 

156 

157 """ 

158 if not isinstance(params, Parameters): 

159 raise TypeError(f"Expected Parameters, got {type(params).__name__}") 

160 if hasattr(values, "to_ical"): 

161 values = values.to_ical() 

162 else: 

163 from icalendar.prop import vText 

164 

165 values = vText(values).to_ical() 

166 # elif isinstance(values, basestring): 

167 # values = escape_char(values) 

168 

169 # TODO: after unicode only, remove this 

170 # Convert back to unicode, after to_ical encoded it. 

171 name = to_unicode(name) 

172 values = to_unicode(values) 

173 if params: 

174 params = to_unicode(params.to_ical(sorted=sorted)) 

175 if params: 

176 # some parameter values can be skipped during serialization 

177 return cls(f"{name};{params}:{values}") 

178 return cls(f"{name}:{values}") 

179 

180 def raw_parts(self) -> tuple[str, Parameters, str]: 

181 """Split the line into ``name``, ``parameters``, and raw ``values`` parts. 

182 

183 This is :meth:`parts` without the unescaping: the values are returned 

184 verbatim, preserving both backslash sequences and URL encoding. It is 

185 used for :rfc:`7265` ``UNKNOWN`` values, whose real value type—and 

186 therefore whose escaping rules—are not known. 

187 

188 See :meth:`parts` for the parts themselves and for examples. 

189 """ 

190 try: 

191 name_split: int | None = None 

192 value_split: int | None = None 

193 in_quotes: bool = False 

194 escaped: bool = False 

195 

196 for i, ch in enumerate(self): 

197 if escaped: 

198 # the previous character was an escaping backslash: 

199 # outside quotes it protects a delimiter, inside quotes 

200 # it protects an escaped quote (``\"``), which must not 

201 # toggle the quote state (:rfc:`2445`-style input). A 

202 # DQUOTE that closes the section is different: preceded 

203 # by a literal backslash and followed by a delimiter or 

204 # the end of the line, it closes (values ending in a 

205 # backslash, as icalendar itself writes them). 

206 escaped = False 

207 if ch == '"' and closes_quoted_section(self, i): 

208 in_quotes = False 

209 elif ch == "\\": 

210 escaped = True 

211 elif ch == '"': 

212 in_quotes = not in_quotes 

213 elif not in_quotes and ch in ":;": 

214 # Find first delimiter for name 

215 if name_split is None: 

216 name_split = i 

217 # Find value delimiter (first colon) 

218 if ch == ":" and value_split is None: 

219 value_split = i 

220 

221 # Validate parsing results 

222 if not value_split: 

223 # No colon found - value is empty, use end of string 

224 value_split = len(self) 

225 

226 # Extract name - if no delimiter, 

227 # take whole string for validate_token to reject 

228 name = self[:name_split] if name_split else self 

229 if not self.strict: 

230 name = re.sub(r"[ \t]+", "", name.strip()) 

231 validate_token(name) 

232 

233 if not name_split or name_split + 1 == value_split: 

234 # No delimiter or empty parameter section 

235 raise ValueError("Invalid content line") # noqa: TRY301 

236 # Parse parameters - they still need to be escaped/unescaped 

237 # for proper handling of commas, semicolons, etc. in parameter values 

238 raw_param_str = self[name_split + 1 : value_split] 

239 if not self.strict: 

240 raw_param_str = _strip_ows_around_delimiters(raw_param_str) 

241 param_str = _escape_string(raw_param_str) 

242 params = Parameters.from_ical(param_str, strict=self.strict) 

243 params = Parameters( 

244 (_unescape_string(key), unescape_list_or_string(value)) 

245 for key, value in iter(params.items()) 

246 ) 

247 values = self[value_split + 1 :] 

248 except ValueError as exc: 

249 raise ValueError( 

250 f"Content line could not be parsed into parts: '{self}': {exc}" 

251 ) from exc 

252 return (name, params, values) 

253 

254 def parts(self) -> tuple[str, Parameters, str]: 

255 """Split the line into ``name``, ``parameters``, and unescaped ``values`` parts. 

256 

257 Properly handles escaping with backslashes and double-quote sections 

258 to avoid corrupting URL-encoded characters in values. 

259 

260 The backslash sequences in the values are unescaped, as for the values 

261 of ``TEXT`` properties, while URL encoding is preserved. Use 

262 :meth:`raw_parts` to get the values verbatim instead. 

263 

264 Examples: 

265 

266 With parameter: 

267 

268 .. code-block:: ics 

269 

270 DESCRIPTION;ALTREP="cid:part1.0001@example.org":The Fall'98 Wild 

271 

272 Without parameters: 

273 

274 .. code-block:: ics 

275 

276 DESCRIPTION:The Fall'98 Wild 

277 """ 

278 name, params, values = self.raw_parts() 

279 return (name, params, unescape_backslash(values)) 

280 

281 def value_separator_index(self) -> int: 

282 r"""Return the index of the colon that separates the value. 

283 

284 This is the first colon that is not inside a quoted parameter section. 

285 

286 A colon inside a quoted parameter value is skipped, for example, 

287 ``ALTREP="http://x"``. A colon that belongs to the value is not 

288 mistaken for the colon separator, since TEXT doesn't escape ``:``. 

289 

290 An escaped double quote (``\"``) inside a quoted section doesn't 

291 close it. :rfc:`2445#section-4.3.11`-compliant input, such as 

292 ``CN="Qu\"ote"``, both keeps its quote state and the separator after 

293 it is still found. 

294 

295 A DQUOTE preceded by a literal backslash closes the section when the 

296 separator or the end of the line follows it. This is the shape 

297 icalendar itself writes for values ending in a backslash. 

298 

299 Returns: 

300 An integer representing the index position of the separator, 

301 or ``-1`` if there is none. 

302 """ 

303 in_quotes = False 

304 escaped = False 

305 for i, ch in enumerate(self): 

306 if in_quotes and escaped: 

307 escaped = False 

308 if ch == '"' and closes_quoted_section(self, i): 

309 in_quotes = False 

310 elif in_quotes and ch == "\\": 

311 escaped = True 

312 elif ch == '"': 

313 in_quotes = not in_quotes 

314 elif ch == ":" and not in_quotes: 

315 return i 

316 return -1 

317 

318 @classmethod 

319 def from_ical(cls, ical, strict=False): 

320 """Unfold the content lines in an iCalendar into long content lines.""" 

321 ical = to_unicode(ical) 

322 # a fold is carriage return followed by either a space or a tab 

323 return cls(UFOLD.sub("", ical), strict=strict) 

324 

325 def to_ical(self): 

326 """Long content lines are folded so they are less than 75 characters 

327 wide. 

328 """ 

329 return _foldline(self).encode(DEFAULT_ENCODING) 

330 

331 

332class Contentlines(list[Contentline]): 

333 """I assume that iCalendar files generally are a few kilobytes in size. 

334 Then this should be efficient. for Huge files, an iterator should probably 

335 be used instead. 

336 """ 

337 

338 def to_ical(self): 

339 """Simply join self.""" 

340 return b"\r\n".join(line.to_ical() for line in self if line) + b"\r\n" 

341 

342 @classmethod 

343 def from_ical(cls, st): 

344 """Parses a string into content lines.""" 

345 st = to_unicode(st) 

346 try: 

347 # a fold is carriage return followed by either a space or a tab 

348 unfolded = UFOLD.sub("", st) 

349 lines = cls(Contentline(line) for line in NEWLINE.split(unfolded) if line) 

350 lines.append("") # '\r\n' at the end of every content line 

351 except Exception as e: 

352 raise ValueError("Expected StringType with content lines") from e 

353 return lines 

354 

355 

356__all__ = ["Contentline", "Contentlines"]