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

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  

1import os 

2import sys 

3from collections.abc import Iterable, Iterator, Sequence 

4from functools import lru_cache 

5from pathlib import Path 

6from re import Pattern 

7from typing import TYPE_CHECKING, Any, TextIO, Union 

8 

9from mypy_extensions import mypyc_attr 

10from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet 

11from packaging.version import InvalidVersion, Version 

12from pathspec import GitIgnoreSpec 

13from pathspec.patterns.gitignore import GitIgnorePatternError 

14 

15if sys.version_info >= (3, 11): 

16 try: 

17 import tomllib 

18 except ImportError: 

19 # Help users on older alphas 

20 if not TYPE_CHECKING: 

21 import tomli as tomllib 

22else: 

23 import tomli as tomllib 

24 

25from black.handle_ipynb_magics import jupyter_dependencies_are_installed 

26from black.mode import TargetVersion 

27from black.output import err 

28from black.report import Report 

29 

30if TYPE_CHECKING: 

31 import colorama 

32 

33 

34@lru_cache 

35def _load_toml(path: Path | str) -> dict[str, Any]: 

36 with open(path, "rb") as f: 

37 return tomllib.load(f) 

38 

39 

40@lru_cache 

41def _cached_resolve(path: Path) -> Path: 

42 return path.resolve() 

43 

44 

45def find_project_root( 

46 srcs: Sequence[str | Path], stdin_filename: str | None = None 

47) -> tuple[Path | None, str | None]: 

48 """Return a directory containing .git, .hg, or pyproject.toml. 

49 

50 pyproject.toml files are only considered if they contain a [tool.black] 

51 section and are ignored otherwise. 

52 

53 That directory will be a common parent of all files and directories 

54 passed in `srcs`. 

55 

56 If no directory in the tree contains a marker that would specify it's the 

57 project root, the root of the file system is returned. 

58 

59 If the sources share no common parent at all (for example, they live on 

60 different drives on Windows), there is no project root and 

61 ``(None, None)`` is returned. 

62 

63 Returns a two-tuple with the first element as the project root path and 

64 the second element as a string describing the method by which the 

65 project root was discovered. 

66 """ 

67 if stdin_filename is not None: 

68 srcs = tuple(stdin_filename if s == "-" else s for s in srcs) 

69 

70 if not srcs: 

71 resolved_srcs: tuple[str, ...] = (str(_cached_resolve(Path.cwd())),) 

72 else: 

73 resolved_srcs = tuple( 

74 str(_cached_resolve(Path(Path.cwd(), src))) for src in srcs 

75 ) 

76 

77 return _find_project_root_cached(resolved_srcs) 

78 

79 

80@lru_cache 

81def _find_project_root_cached(srcs: tuple[str, ...]) -> tuple[Path | None, str | None]: 

82 path_srcs = [Path(src) for src in srcs] 

83 

84 # A list of lists of parents for each 'src'. 'src' is included as a 

85 # "parent" of itself if it is a directory 

86 src_parents = [ 

87 list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs 

88 ] 

89 

90 common_parents = set.intersection(*(set(parents) for parents in src_parents)) 

91 if not common_parents: 

92 return None, None 

93 common_base = max(common_parents, key=lambda path: path.parts) 

94 

95 for directory in (common_base, *common_base.parents): 

96 if (directory / ".git").exists(): 

97 return directory, ".git directory" 

98 

99 if (directory / ".hg").is_dir(): 

100 return directory, ".hg directory" 

101 

102 if (directory / "pyproject.toml").is_file(): 

103 pyproject_toml = _load_toml(directory / "pyproject.toml") 

104 if "black" in pyproject_toml.get("tool", {}): 

105 return directory, "pyproject.toml" 

106 

107 return directory, "file system root" 

108 

109 

110def find_pyproject_toml( 

111 path_search_start: tuple[str, ...], stdin_filename: str | None = None 

112) -> str | None: 

113 """Find the absolute filepath to a pyproject.toml if it exists""" 

114 path_project_root, _ = find_project_root(path_search_start, stdin_filename) 

115 if path_project_root is not None: 

116 path_pyproject_toml = path_project_root / "pyproject.toml" 

117 if path_pyproject_toml.is_file(): 

118 return str(path_pyproject_toml) 

119 

120 try: 

121 path_user_pyproject_toml = find_user_pyproject_toml() 

122 return ( 

123 str(path_user_pyproject_toml) 

124 if path_user_pyproject_toml.is_file() 

125 else None 

126 ) 

127 except (PermissionError, RuntimeError) as e: 

128 # We do not have access to the user-level config directory, so ignore it. 

129 err(f"Ignoring user configuration directory due to {e!r}") 

130 return None 

131 

132 

133@mypyc_attr(patchable=True) 

134def parse_pyproject_toml(path_config: str) -> dict[str, Any]: 

135 """Parse a pyproject toml file, pulling out relevant parts for Black. 

136 

137 If parsing fails or the Black configuration is not a table, raises ValueError. 

138 """ 

139 pyproject_toml = _load_toml(path_config) 

140 config: dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {}) 

141 if not isinstance(config, dict): 

142 raise ValueError("Config key tool.black must be a table") 

143 config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} 

144 

145 if "target_version" not in config: 

146 inferred_target_version = infer_target_version(pyproject_toml) 

147 if inferred_target_version is not None: 

148 config["target_version"] = [v.name.lower() for v in inferred_target_version] 

149 

150 return config 

151 

152 

153def infer_target_version( 

154 pyproject_toml: dict[str, Any], 

155) -> list[TargetVersion] | None: 

156 """Infer Black's target version from the project metadata in pyproject.toml. 

157 

158 Supports the PyPA standard format (PEP 621): 

159 https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python 

160 

161 If the target version cannot be inferred, returns None. 

162 """ 

163 project_metadata = pyproject_toml.get("project", {}) 

164 requires_python = project_metadata.get("requires-python", None) 

165 if requires_python is not None: 

166 try: 

167 return parse_req_python_version(requires_python) 

168 except InvalidVersion: 

169 pass 

170 try: 

171 return parse_req_python_specifier(requires_python) 

172 except (InvalidSpecifier, InvalidVersion): 

173 pass 

174 

175 return None 

176 

177 

178def parse_req_python_version(requires_python: str) -> list[TargetVersion] | None: 

179 """Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion. 

180 

181 If parsing fails, will raise a packaging.version.InvalidVersion error. 

182 If the parsed version cannot be mapped to a valid TargetVersion, returns None. 

183 """ 

184 version = Version(requires_python) 

185 if version.release[0] != 3: 

186 return None 

187 try: 

188 return [TargetVersion(version.release[1])] 

189 except (IndexError, ValueError): 

190 return None 

191 

192 

193def parse_req_python_specifier(requires_python: str) -> list[TargetVersion] | None: 

194 """Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion. 

195 

196 If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error. 

197 If the parsed specifier cannot be mapped to a valid TargetVersion, returns None. 

198 """ 

199 specifier_set = strip_specifier_set(SpecifierSet(requires_python)) 

200 if not specifier_set: 

201 return None 

202 

203 target_version_map = {f"3.{v.value}": v for v in TargetVersion} 

204 compatible_versions: list[str] = list(specifier_set.filter(target_version_map)) 

205 if compatible_versions: 

206 return [target_version_map[v] for v in compatible_versions] 

207 return None 

208 

209 

210def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet: 

211 """Strip minor versions for some specifiers in the specifier set. 

212 

213 For background on version specifiers, see PEP 440: 

214 https://peps.python.org/pep-0440/#version-specifiers 

215 """ 

216 specifiers = [] 

217 for s in specifier_set: 

218 if "*" in str(s): 

219 specifiers.append(s) 

220 elif s.operator in ["~=", "==", ">=", "==="]: 

221 version = Version(s.version) 

222 stripped = Specifier(f"{s.operator}{version.major}.{version.minor}") 

223 specifiers.append(stripped) 

224 elif s.operator == ">": 

225 version = Version(s.version) 

226 if len(version.release) > 2: 

227 s = Specifier(f">={version.major}.{version.minor}") 

228 specifiers.append(s) 

229 else: 

230 specifiers.append(s) 

231 

232 return SpecifierSet(",".join(str(s) for s in specifiers)) 

233 

234 

235@lru_cache 

236def find_user_pyproject_toml() -> Path: 

237 r"""Return the path to the top-level user configuration for black. 

238 

239 This looks for ~\.black on Windows and ~/.config/black on Linux and other 

240 Unix systems. 

241 

242 May raise: 

243 - RuntimeError: if the current user has no homedir 

244 - PermissionError: if the current process cannot access the user's homedir 

245 """ 

246 if sys.platform == "win32": 

247 # Windows 

248 user_config_path = Path.home() / ".black" 

249 else: 

250 config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config") 

251 user_config_path = Path(config_root).expanduser() / "black" 

252 return _cached_resolve(user_config_path) 

253 

254 

255@lru_cache 

256def get_gitignore(root: Path | None) -> GitIgnoreSpec: 

257 """Return a GitIgnoreSpec matching gitignore content if present.""" 

258 if root is None: 

259 return GitIgnoreSpec.from_lines([]) 

260 gitignore = root / ".gitignore" 

261 lines: list[str] = [] 

262 if gitignore.is_file(): 

263 # Like git, skip a UTF-8 byte order mark. Otherwise it becomes part of the 

264 # first pattern, which then never matches. 

265 with gitignore.open(encoding="utf-8-sig") as gf: 

266 lines = gf.readlines() 

267 try: 

268 return GitIgnoreSpec.from_lines(lines) 

269 except GitIgnorePatternError as e: 

270 err(f"Could not parse {gitignore}: {e}") 

271 raise 

272 

273 

274def resolves_outside_root_or_cannot_stat( 

275 path: Path, 

276 root: Path, 

277 report: Report | None = None, 

278) -> bool: 

279 """ 

280 Returns whether the path is a symbolic link that points outside the 

281 root directory. Also returns True if we failed to resolve the path. 

282 """ 

283 try: 

284 resolved_path = _cached_resolve(path) 

285 except OSError as e: 

286 if report: 

287 report.path_ignored(path, f"cannot be read because {e}") 

288 return True 

289 try: 

290 resolved_path.relative_to(root) 

291 except ValueError: 

292 if report: 

293 report.path_ignored(path, f"is a symbolic link that points outside {root}") 

294 return True 

295 return False 

296 

297 

298def _collapse_parent_dirs(relative_path: Path) -> Path: 

299 """Collapse ".." in a root-relative path without resolving symlinks. 

300 

301 This way exclusion regexes see "generated/x.py" rather than 

302 "src/../generated/x.py". If a symlink makes the collapsed path leave the 

303 root, the path is kept as given. 

304 """ 

305 collapsed = Path(os.path.normpath(relative_path)) 

306 if collapsed.parts[:1] == ("..",): 

307 return relative_path 

308 return collapsed 

309 

310 

311def best_effort_relative_path(path: Path, root: Path) -> Path: 

312 # Precondition: resolves_outside_root_or_cannot_stat(path, root) is False 

313 try: 

314 return _collapse_parent_dirs(path.absolute().relative_to(root)) 

315 except ValueError: 

316 pass 

317 root_parent = next((p for p in path.parents if _cached_resolve(p) == root), None) 

318 if root_parent is not None: 

319 return _collapse_parent_dirs(path.relative_to(root_parent)) 

320 # something adversarial, fallback to path guaranteed by precondition 

321 return _cached_resolve(path).relative_to(root) 

322 

323 

324def _path_is_ignored( 

325 root_relative_path: str, 

326 root: Path, 

327 gitignore_dict: dict[Path, GitIgnoreSpec], 

328) -> bool: 

329 path = root / root_relative_path 

330 # Note that this logic is sensitive to the ordering of gitignore_dict. Callers must 

331 # ensure that gitignore_dict is ordered from least specific to most specific. 

332 for gitignore_path, pattern in gitignore_dict.items(): 

333 try: 

334 relative_path = path.relative_to(gitignore_path).as_posix() 

335 if path.is_dir(): 

336 relative_path = relative_path + "/" 

337 except ValueError: 

338 break 

339 if pattern.match_file(relative_path): 

340 return True 

341 return False 

342 

343 

344def path_is_excluded( 

345 normalized_path: str, 

346 pattern: Pattern[str] | None, 

347) -> bool: 

348 match = pattern.search(normalized_path) if pattern else None 

349 return bool(match and match.group(0)) 

350 

351 

352def gen_python_files( 

353 paths: Iterable[Path], 

354 root: Path, 

355 include: Pattern[str], 

356 exclude: Pattern[str], 

357 extend_exclude: Pattern[str] | None, 

358 force_exclude: Pattern[str] | None, 

359 report: Report, 

360 gitignore_dict: dict[Path, GitIgnoreSpec] | None, 

361 *, 

362 verbose: bool, 

363 quiet: bool, 

364) -> Iterator[Path]: 

365 """Generate all files under `path` whose paths are not excluded by the 

366 `exclude_regex`, `extend_exclude`, or `force_exclude` regexes, 

367 but are included by the `include` regex. 

368 

369 Symbolic links pointing outside of the `root` directory are ignored. 

370 

371 `report` is where output about exclusions goes. 

372 """ 

373 

374 assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}" 

375 for child in paths: 

376 assert child.is_absolute() 

377 root_relative_path = child.relative_to(root).as_posix() 

378 

379 # First ignore files matching .gitignore, if passed 

380 if gitignore_dict and _path_is_ignored( 

381 root_relative_path, root, gitignore_dict 

382 ): 

383 report.path_ignored(child, "matches a .gitignore file content") 

384 continue 

385 

386 # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options. 

387 root_relative_path = "/" + root_relative_path 

388 if child.is_dir(): 

389 root_relative_path += "/" 

390 

391 if path_is_excluded(root_relative_path, exclude): 

392 report.path_ignored(child, "matches the --exclude regular expression") 

393 continue 

394 

395 if path_is_excluded(root_relative_path, extend_exclude): 

396 report.path_ignored( 

397 child, "matches the --extend-exclude regular expression" 

398 ) 

399 continue 

400 

401 if path_is_excluded(root_relative_path, force_exclude): 

402 report.path_ignored(child, "matches the --force-exclude regular expression") 

403 continue 

404 

405 if resolves_outside_root_or_cannot_stat(child, root, report): 

406 continue 

407 

408 if child.is_dir(): 

409 # If gitignore is None, gitignore usage is disabled, while a Falsey 

410 # gitignore is when the directory doesn't have a .gitignore file. 

411 if gitignore_dict is not None: 

412 new_gitignore_dict = { 

413 **gitignore_dict, 

414 root / child: get_gitignore(child), 

415 } 

416 else: 

417 new_gitignore_dict = None 

418 yield from gen_python_files( 

419 child.iterdir(), 

420 root, 

421 include, 

422 exclude, 

423 extend_exclude, 

424 force_exclude, 

425 report, 

426 new_gitignore_dict, 

427 verbose=verbose, 

428 quiet=quiet, 

429 ) 

430 

431 elif child.is_file(): 

432 if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed( 

433 warn=verbose or not quiet 

434 ): 

435 continue 

436 if include.search(root_relative_path): 

437 yield child 

438 

439 

440def wrap_stream_for_windows( 

441 f: TextIO, 

442) -> Union[TextIO, "colorama.AnsiToWin32"]: 

443 """ 

444 Wrap stream with colorama's wrap_stream so colors are shown on Windows. 

445 

446 If `colorama` is unavailable, the original stream is returned unmodified. 

447 Otherwise, the `wrap_stream()` function determines whether the stream needs 

448 to be wrapped for a Windows environment and will accordingly either return 

449 an `AnsiToWin32` wrapper or the original stream. 

450 """ 

451 try: 

452 from colorama.initialise import wrap_stream 

453 except ImportError: 

454 return f 

455 else: 

456 # Set `strip=False` to avoid needing to modify test_express_diff_with_color. 

457 return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)