Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/black/strings.py: 14%

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

244 statements  

1""" 

2Simple formatting on strings. Further string formatting code is in trans.py. 

3""" 

4 

5import re 

6import sys 

7from functools import lru_cache 

8from re import Match, Pattern 

9from typing import Final 

10 

11from black._width_table import WIDTH_TABLE 

12from black.mode import Mode, Preview 

13from blib2to3.pgen2 import token 

14from blib2to3.pytree import Leaf, Node 

15 

16STRING_PREFIX_CHARS: Final = "fturbFTURB" # All possible string prefix characters. 

17STRING_PREFIX_RE: Final = re.compile( 

18 r"^([" + STRING_PREFIX_CHARS + r"]*)(.*)$", re.DOTALL 

19) 

20UNICODE_ESCAPE_RE: Final = re.compile( 

21 r"(?P<backslashes>\\+)(?P<body>" 

22 r"(u(?P<u>[a-fA-F0-9]{4}))" # Character with 16-bit hex value xxxx 

23 r"|(U(?P<U>[a-fA-F0-9]{8}))" # Character with 32-bit hex value xxxxxxxx 

24 r"|(x(?P<x>[a-fA-F0-9]{2}))" # Character with hex value hh 

25 r"|(N\{(?P<N>[a-zA-Z0-9 \-]{2,})\})" # Character named name in the Unicode database 

26 r")?", 

27 re.VERBOSE, 

28) 

29# The line breaks the Python parser recognizes, and therefore the only ones that 

30# may be treated as line endings inside a string. str.splitlines() also breaks 

31# on form feed, vertical tab, NEL, the Unicode line and paragraph separators and 

32# the C0 separators, all of which are ordinary characters of a string's value. 

33# See output._splitlines_no_ff, which splits source code the same way. 

34LINE_BREAK_RE: Final = re.compile(r"\r\n|[\r\n]") 

35 

36 

37def sub_twice(regex: Pattern[str], replacement: str, original: str) -> str: 

38 """Replace `regex` with `replacement` twice on `original`. 

39 

40 This is used by string normalization to perform replaces on 

41 overlapping matches. 

42 """ 

43 return regex.sub(replacement, regex.sub(replacement, original)) 

44 

45 

46def has_triple_quotes(string: str) -> bool: 

47 """ 

48 Returns: 

49 True iff @string starts with three quotation characters. 

50 """ 

51 raw_string = string.lstrip(STRING_PREFIX_CHARS) 

52 return raw_string[:3] in {'"""', "'''"} 

53 

54 

55def lines_with_leading_tabs_expanded(s: str) -> list[str]: 

56 """ 

57 Splits string into lines and expands only leading tabs. 

58 

59 Black normalizes code indentation to four-space columns, so leading tabs in 

60 docstrings need the same width to keep relative indentation stable. 

61 """ 

62 lines = [] 

63 for line in LINE_BREAK_RE.split(s): 

64 stripped_line = line.lstrip() 

65 if not stripped_line or stripped_line == line: 

66 lines.append(line) 

67 else: 

68 prefix_length = len(line) - len(stripped_line) 

69 prefix = line[:prefix_length].expandtabs(4) 

70 lines.append(prefix + stripped_line) 

71 return lines 

72 

73 

74def fix_multiline_docstring(docstring: str, prefix: str) -> str: 

75 # https://peps.python.org/pep-0257/#handling-docstring-indentation 

76 assert docstring, "INTERNAL ERROR: Multiline docstrings cannot be empty" 

77 lines = lines_with_leading_tabs_expanded(docstring) 

78 # Determine minimum indentation (first line doesn't count): 

79 indent = sys.maxsize 

80 for line in lines[1:]: 

81 stripped = line.lstrip() 

82 if stripped: 

83 indent = min(indent, len(line) - len(stripped)) 

84 # Remove indentation (first line is special): 

85 trimmed = [lines[0].strip()] 

86 if indent < sys.maxsize: 

87 last_line_idx = len(lines) - 2 

88 for i, line in enumerate(lines[1:]): 

89 stripped_line = line[indent:].rstrip() 

90 if stripped_line or i == last_line_idx: 

91 trimmed.append(prefix + stripped_line) 

92 else: 

93 trimmed.append("") 

94 return "\n".join(trimmed) 

95 

96 

97def get_string_prefix(string: str) -> str: 

98 """ 

99 Pre-conditions: 

100 * assert_is_leaf_string(@string) 

101 

102 Returns: 

103 @string's prefix (e.g. '', 'r', 'f', or 'rf'). 

104 """ 

105 assert_is_leaf_string(string) 

106 

107 prefix = [] 

108 for char in string: 

109 if char in STRING_PREFIX_CHARS: 

110 prefix.append(char) 

111 else: 

112 break 

113 return "".join(prefix) 

114 

115 

116def assert_is_leaf_string(string: str) -> None: 

117 """ 

118 Checks the pre-condition that @string has the format that you would expect 

119 of `leaf.value` where `leaf` is some Leaf such that `leaf.type == 

120 token.STRING`. A more precise description of the pre-conditions that are 

121 checked are listed below. 

122 

123 Pre-conditions: 

124 * @string starts with either ', ", <prefix>', or <prefix>" where 

125 `set(<prefix>)` is some subset of `set(STRING_PREFIX_CHARS)`. 

126 * @string ends with a quote character (' or "). 

127 

128 Raises: 

129 AssertionError(...) if the pre-conditions listed above are not 

130 satisfied. 

131 """ 

132 dquote_idx = string.find('"') 

133 squote_idx = string.find("'") 

134 if -1 in [dquote_idx, squote_idx]: 

135 quote_idx = max(dquote_idx, squote_idx) 

136 else: 

137 quote_idx = min(squote_idx, dquote_idx) 

138 

139 assert ( 

140 0 <= quote_idx < len(string) - 1 

141 ), f"{string!r} is missing a starting quote character (' or \")." 

142 assert string[-1] in ( 

143 "'", 

144 '"', 

145 ), f"{string!r} is missing an ending quote character (' or \")." 

146 assert set(string[:quote_idx]).issubset( 

147 set(STRING_PREFIX_CHARS) 

148 ), f"{set(string[:quote_idx])} is NOT a subset of {set(STRING_PREFIX_CHARS)}." 

149 

150 

151def normalize_string_prefix(s: str, mode: Mode) -> str: 

152 """Make all string prefixes lowercase.""" 

153 match = STRING_PREFIX_RE.match(s) 

154 assert match is not None, f"failed to match string {s!r}" 

155 orig_prefix = match.group(1) 

156 new_prefix = ( 

157 orig_prefix.replace("F", "f") 

158 .replace("B", "b") 

159 .replace("U", "") 

160 .replace("u", "") 

161 ) 

162 

163 if Preview.normalize_tstring_prefix in mode: 

164 new_prefix = new_prefix.replace("T", "t") 

165 # Python syntax guarantees max 2 prefixes and that one of them is "r" 

166 if len(new_prefix) == 2 and new_prefix[0].lower() != "r": 

167 new_prefix = new_prefix[::-1] 

168 return f"{new_prefix}{match.group(2)}" 

169 

170 

171# Re(gex) does actually cache patterns internally but this still improves 

172# performance on a long list literal of strings by 5-9% since lru_cache's 

173# caching overhead is much lower. 

174@lru_cache(maxsize=64) 

175def _cached_compile(pattern: str) -> Pattern[str]: 

176 return re.compile(pattern) 

177 

178 

179def _ends_with_unescaped_quote(body: str) -> bool: 

180 """Does `body` end in a `"` that is not already backslash-escaped? 

181 

182 A backslash only escapes the quote when it is not itself escaped, so the run 

183 of backslashes in front of the quote has to be of even length for the quote 

184 to still need escaping. 

185 """ 

186 if body[-1:] != '"': 

187 return False 

188 

189 preceding = body[:-1] 

190 backslashes = len(preceding) - len(preceding.rstrip("\\")) 

191 return backslashes % 2 == 0 

192 

193 

194def normalize_string_quotes(s: str) -> str: 

195 """Prefer double quotes but only if it doesn't cause more escaping. 

196 

197 Adds or removes backslashes as appropriate. 

198 """ 

199 value = s.lstrip(STRING_PREFIX_CHARS) 

200 if value[:3] == '"""': 

201 return s 

202 

203 elif value[:3] == "'''": 

204 orig_quote = "'''" 

205 new_quote = '"""' 

206 elif value[0] == '"': 

207 orig_quote = '"' 

208 new_quote = "'" 

209 else: 

210 orig_quote = "'" 

211 new_quote = '"' 

212 first_quote_pos = s.find(orig_quote) 

213 assert first_quote_pos != -1, f"INTERNAL ERROR: Malformed string {s!r}" 

214 

215 prefix = s[:first_quote_pos] 

216 unescaped_new_quote = _cached_compile(rf"(([^\\]|^)(\\\\)*){new_quote}") 

217 escaped_new_quote = _cached_compile(rf"([^\\]|^)\\((?:\\\\)*){new_quote}") 

218 escaped_orig_quote = _cached_compile(rf"([^\\]|^)\\((?:\\\\)*){orig_quote}") 

219 body = s[first_quote_pos + len(orig_quote) : -len(orig_quote)] 

220 if "r" in prefix.casefold(): 

221 if unescaped_new_quote.search(body): 

222 # There's at least one unescaped new_quote in this raw string 

223 # so converting is impossible 

224 return s 

225 

226 # Do not introduce or remove backslashes in raw strings 

227 new_body = body 

228 else: 

229 # remove unnecessary escapes 

230 new_body = sub_twice(escaped_new_quote, rf"\1\2{new_quote}", body) 

231 if body != new_body: 

232 # Consider the string without unnecessary escapes as the original 

233 body = new_body 

234 s = f"{prefix}{orig_quote}{body}{orig_quote}" 

235 new_body = sub_twice(escaped_orig_quote, rf"\1\2{orig_quote}", new_body) 

236 new_body = sub_twice(unescaped_new_quote, rf"\1\\{new_quote}", new_body) 

237 

238 if "f" in prefix.casefold() or "t" in prefix.casefold(): 

239 matches = re.findall( 

240 r""" 

241 (?:(?<!\{)|^)\{ # start of the string or a non-{ followed by a single { 

242 ([^{].*?) # contents of the brackets except if begins with {{ 

243 \}(?:(?!\})|$) # A } followed by end of the string or a non-} 

244 """, 

245 new_body, 

246 re.VERBOSE, 

247 ) 

248 for m in matches: 

249 if "\\" in str(m): 

250 # Do not introduce backslashes in interpolated expressions 

251 return s 

252 

253 if new_quote == '"""' and _ends_with_unescaped_quote(new_body): 

254 # edge case: 

255 new_body = new_body[:-1] + '\\"' 

256 orig_escape_count = body.count("\\") 

257 new_escape_count = new_body.count("\\") 

258 if new_escape_count > orig_escape_count: 

259 return s # Do not introduce more escaping 

260 

261 if new_escape_count == orig_escape_count and orig_quote == '"': 

262 return s # Prefer double quotes 

263 

264 return f"{prefix}{new_quote}{new_body}{new_quote}" 

265 

266 

267# TODO: only used in currently disabled f-string formatting 

268def normalize_fstring_quotes( 

269 quote: str, 

270 middles: list[Leaf], 

271 is_raw_fstring: bool, 

272) -> tuple[list[Leaf], str]: 

273 """Prefer double quotes but only if it doesn't cause more escaping. 

274 

275 Adds or removes backslashes as appropriate. 

276 """ 

277 if quote == '"""': 

278 return middles, quote 

279 

280 elif quote == "'''": 

281 new_quote = '"""' 

282 elif quote == '"': 

283 new_quote = "'" 

284 else: 

285 new_quote = '"' 

286 

287 unescaped_new_quote = _cached_compile(rf"(([^\\]|^)(\\\\)*){new_quote}") 

288 escaped_new_quote = _cached_compile(rf"([^\\]|^)\\((?:\\\\)*){new_quote}") 

289 escaped_orig_quote = _cached_compile(rf"([^\\]|^)\\((?:\\\\)*){quote}") 

290 if is_raw_fstring: 

291 for middle in middles: 

292 if unescaped_new_quote.search(middle.value): 

293 # There's at least one unescaped new_quote in this raw string 

294 # so converting is impossible 

295 return middles, quote 

296 

297 # Do not introduce or remove backslashes in raw strings, just use double quote 

298 return middles, '"' 

299 

300 new_segments = [] 

301 for middle in middles: 

302 segment = middle.value 

303 # remove unnecessary escapes 

304 new_segment = sub_twice(escaped_new_quote, rf"\1\2{new_quote}", segment) 

305 if segment != new_segment: 

306 # Consider the string without unnecessary escapes as the original 

307 middle.value = new_segment 

308 

309 new_segment = sub_twice(escaped_orig_quote, rf"\1\2{quote}", new_segment) 

310 new_segment = sub_twice(unescaped_new_quote, rf"\1\\{new_quote}", new_segment) 

311 new_segments.append(new_segment) 

312 

313 if new_quote == '"""' and _ends_with_unescaped_quote(new_segments[-1]): 

314 # edge case: 

315 new_segments[-1] = new_segments[-1][:-1] + '\\"' 

316 

317 orig_escape_count = 0 

318 new_escape_count = 0 

319 for middle, new_segment in zip(middles, new_segments, strict=True): 

320 orig_escape_count += middle.value.count("\\") 

321 new_escape_count += new_segment.count("\\") 

322 

323 if new_escape_count > orig_escape_count: 

324 return middles, quote # Do not introduce more escaping 

325 

326 if new_escape_count == orig_escape_count and quote == '"': 

327 return middles, quote # Prefer double quotes 

328 

329 for middle, new_segment in zip(middles, new_segments, strict=True): 

330 middle.value = new_segment 

331 

332 return middles, new_quote 

333 

334 

335def _normalize_unicode_escape_sequences_in_text( 

336 text: str, is_bytes: bool = False 

337) -> str: 

338 def replace(m: Match[str]) -> str: 

339 groups = m.groupdict() 

340 back_slashes = groups["backslashes"] 

341 

342 if groups["body"] is None or len(back_slashes) % 2 == 0: 

343 return m.group(0) 

344 

345 if is_bytes and not groups["x"]: 

346 # \u, \U and \N{} are not escape sequences in bytes literals 

347 return m.group(0) 

348 

349 if groups["u"]: 

350 # \u 

351 return back_slashes + "u" + groups["u"].lower() 

352 elif groups["U"]: 

353 # \U 

354 return back_slashes + "U" + groups["U"].lower() 

355 elif groups["x"]: 

356 # \x 

357 return back_slashes + "x" + groups["x"].lower() 

358 else: 

359 assert groups["N"], f"Unexpected match: {m}" 

360 # \N{} 

361 return back_slashes + "N{" + groups["N"].upper() + "}" 

362 

363 return re.sub(UNICODE_ESCAPE_RE, replace, text) 

364 

365 

366def normalize_unicode_escape_sequences(leaf: Leaf) -> None: 

367 """Replace hex codes in Unicode escape sequences with lowercase representation.""" 

368 text = leaf.value 

369 prefix = get_string_prefix(text) 

370 if "r" in prefix.lower(): 

371 return 

372 

373 leaf.value = _normalize_unicode_escape_sequences_in_text( 

374 text, is_bytes="b" in prefix.lower() 

375 ) 

376 

377 

378def _normalize_fstring_field_node(node: Leaf | Node) -> None: 

379 from black.nodes import syms 

380 

381 if isinstance(node, Leaf): 

382 if node.type == token.STRING: 

383 normalize_unicode_escape_sequences(node) 

384 return 

385 

386 if node.type in (syms.fstring, syms.tstring): 

387 normalize_fstring_unicode_escape_sequences(node) 

388 return 

389 

390 for child in node.children: 

391 _normalize_fstring_field_node(child) 

392 

393 

394def normalize_fstring_unicode_escape_sequences(node: Node) -> None: 

395 """Replace hex codes in Unicode escape sequences with lowercase representation 

396 within an f-string or t-string node.""" 

397 from black.nodes import syms 

398 

399 if not node.children: 

400 return 

401 

402 first_child = node.children[0] 

403 is_raw_fstring = ( 

404 isinstance(first_child, Leaf) and "r" in first_child.value.rstrip("'\"").lower() 

405 ) 

406 

407 for child in node.children: 

408 if child.type in (token.FSTRING_MIDDLE, token.TSTRING_MIDDLE): 

409 if not is_raw_fstring: 

410 assert isinstance(child, Leaf) 

411 child.value = _normalize_unicode_escape_sequences_in_text(child.value) 

412 elif child.type in ( 

413 syms.fstring_replacement_field, 

414 syms.tstring_replacement_field, 

415 ): 

416 # If this is a debug f-string (e.g. f"{foo=}"), do not touch the 

417 # expression before '=', as Python preserves the exact source expression. 

418 if any( 

419 isinstance(c, Leaf) and c.type == token.EQUAL and c.value == "=" 

420 for c in child.children 

421 ): 

422 continue 

423 

424 for subchild in child.children: 

425 _normalize_fstring_field_node(subchild) 

426 

427 

428@lru_cache(maxsize=4096) 

429def char_width(char: str) -> int: 

430 """Return the width of a single character as it would be displayed in a 

431 terminal or editor (which respects Unicode East Asian Width). 

432 

433 Full width characters are counted as 2, while half width characters are 

434 counted as 1. Also control characters are counted as 0. 

435 """ 

436 table = WIDTH_TABLE 

437 codepoint = ord(char) 

438 highest = len(table) - 1 

439 lowest = 0 

440 idx = highest // 2 

441 while True: 

442 start_codepoint, end_codepoint, width = table[idx] 

443 if codepoint < start_codepoint: 

444 highest = idx - 1 

445 elif codepoint > end_codepoint: 

446 lowest = idx + 1 

447 else: 

448 return 0 if width < 0 else width 

449 if highest < lowest: 

450 break 

451 idx = (highest + lowest) // 2 

452 return 1 

453 

454 

455def str_width(line_str: str) -> int: 

456 """Return the width of `line_str` as it would be displayed in a terminal 

457 or editor (which respects Unicode East Asian Width). 

458 

459 You could utilize this function to determine, for example, if a string 

460 is too wide to display in a terminal or editor. 

461 """ 

462 if line_str.isascii(): 

463 # Fast path for a line consisting of only ASCII characters 

464 return len(line_str) 

465 return sum(map(char_width, line_str)) 

466 

467 

468def count_chars_in_width(line_str: str, max_width: int) -> int: 

469 """Count the number of characters in `line_str` that would fit in a 

470 terminal or editor of `max_width` (which respects Unicode East Asian 

471 Width). 

472 """ 

473 total_width = 0 

474 for i, char in enumerate(line_str): 

475 width = char_width(char) 

476 if width + total_width > max_width: 

477 return i 

478 total_width += width 

479 return len(line_str)