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

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

218 statements  

1import io 

2import os 

3import sys 

4from collections.abc import Iterable, Iterator, Sequence 

5from functools import lru_cache 

6from pathlib import Path 

7from re import Pattern 

8from typing import TYPE_CHECKING, Any, Union 

9 

10from mypy_extensions import mypyc_attr 

11from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet 

12from packaging.version import InvalidVersion, Version 

13from pathspec import GitIgnoreSpec 

14from pathspec.patterns.gitignore import GitIgnorePatternError 

15 

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

17 try: 

18 import tomllib 

19 except ImportError: 

20 # Help users on older alphas 

21 if not TYPE_CHECKING: 

22 import tomli as tomllib 

23else: 

24 import tomli as tomllib 

25 

26from black.handle_ipynb_magics import jupyter_dependencies_are_installed 

27from black.mode import TargetVersion 

28from black.output import err 

29from black.report import Report 

30 

31if TYPE_CHECKING: 

32 import colorama 

33 

34 

35@lru_cache 

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

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

38 return tomllib.load(f) 

39 

40 

41@lru_cache 

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

43 return path.resolve() 

44 

45 

46def find_project_root( 

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

48) -> tuple[Path, str]: 

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

50 

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

52 section and are ignored otherwise. 

53 

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

55 passed in `srcs`. 

56 

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

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

59 

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

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

62 project root was discovered. 

63 """ 

64 if stdin_filename is not None: 

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

66 

67 if not srcs: 

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

69 else: 

70 resolved_srcs = tuple( 

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

72 ) 

73 

74 return _find_project_root_cached(resolved_srcs) 

75 

76 

77@lru_cache 

78def _find_project_root_cached(srcs: tuple[str, ...]) -> tuple[Path, str]: 

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

80 

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

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

83 src_parents = [ 

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

85 ] 

86 

87 common_base = max( 

88 set.intersection(*(set(parents) for parents in src_parents)), 

89 key=lambda path: path.parts, 

90 ) 

91 

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

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

94 return directory, ".git directory" 

95 

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

97 return directory, ".hg directory" 

98 

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

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

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

102 return directory, "pyproject.toml" 

103 

104 return directory, "file system root" 

105 

106 

107def find_pyproject_toml( 

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

109) -> str | None: 

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

111 path_project_root, _ = find_project_root(path_search_start, stdin_filename) 

112 path_pyproject_toml = path_project_root / "pyproject.toml" 

113 if path_pyproject_toml.is_file(): 

114 return str(path_pyproject_toml) 

115 

116 try: 

117 path_user_pyproject_toml = find_user_pyproject_toml() 

118 return ( 

119 str(path_user_pyproject_toml) 

120 if path_user_pyproject_toml.is_file() 

121 else None 

122 ) 

123 except (PermissionError, RuntimeError) as e: 

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

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

126 return None 

127 

128 

129@mypyc_attr(patchable=True) 

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

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

132 

133 If parsing fails, will raise a tomllib.TOMLDecodeError. 

134 """ 

135 pyproject_toml = _load_toml(path_config) 

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

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

138 

139 if "target_version" not in config: 

140 inferred_target_version = infer_target_version(pyproject_toml) 

141 if inferred_target_version is not None: 

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

143 

144 return config 

145 

146 

147def infer_target_version( 

148 pyproject_toml: dict[str, Any], 

149) -> list[TargetVersion] | None: 

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

151 

152 Supports the PyPA standard format (PEP 621): 

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

154 

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

156 """ 

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

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

159 if requires_python is not None: 

160 try: 

161 return parse_req_python_version(requires_python) 

162 except InvalidVersion: 

163 pass 

164 try: 

165 return parse_req_python_specifier(requires_python) 

166 except (InvalidSpecifier, InvalidVersion): 

167 pass 

168 

169 return None 

170 

171 

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

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

174 

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

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

177 """ 

178 version = Version(requires_python) 

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

180 return None 

181 try: 

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

183 except (IndexError, ValueError): 

184 return None 

185 

186 

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

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

189 

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

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

192 """ 

193 specifier_set = strip_specifier_set(SpecifierSet(requires_python)) 

194 if not specifier_set: 

195 return None 

196 

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

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

199 if compatible_versions: 

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

201 return None 

202 

203 

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

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

206 

207 For background on version specifiers, see PEP 440: 

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

209 """ 

210 specifiers = [] 

211 for s in specifier_set: 

212 if "*" in str(s): 

213 specifiers.append(s) 

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

215 version = Version(s.version) 

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

217 specifiers.append(stripped) 

218 elif s.operator == ">": 

219 version = Version(s.version) 

220 if len(version.release) > 2: 

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

222 specifiers.append(s) 

223 else: 

224 specifiers.append(s) 

225 

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

227 

228 

229@lru_cache 

230def find_user_pyproject_toml() -> Path: 

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

232 

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

234 Unix systems. 

235 

236 May raise: 

237 - RuntimeError: if the current user has no homedir 

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

239 """ 

240 if sys.platform == "win32": 

241 # Windows 

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

243 else: 

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

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

246 return _cached_resolve(user_config_path) 

247 

248 

249@lru_cache 

250def get_gitignore(root: Path) -> GitIgnoreSpec: 

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

252 gitignore = root / ".gitignore" 

253 lines: list[str] = [] 

254 if gitignore.is_file(): 

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

256 lines = gf.readlines() 

257 try: 

258 return GitIgnoreSpec.from_lines(lines) 

259 except GitIgnorePatternError as e: 

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

261 raise 

262 

263 

264def resolves_outside_root_or_cannot_stat( 

265 path: Path, 

266 root: Path, 

267 report: Report | None = None, 

268) -> bool: 

269 """ 

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

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

272 """ 

273 try: 

274 resolved_path = _cached_resolve(path) 

275 except OSError as e: 

276 if report: 

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

278 return True 

279 try: 

280 resolved_path.relative_to(root) 

281 except ValueError: 

282 if report: 

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

284 return True 

285 return False 

286 

287 

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

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

290 try: 

291 return path.absolute().relative_to(root) 

292 except ValueError: 

293 pass 

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

295 if root_parent is not None: 

296 return path.relative_to(root_parent) 

297 # something adversarial, fallback to path guaranteed by precondition 

298 return _cached_resolve(path).relative_to(root) 

299 

300 

301def _path_is_ignored( 

302 root_relative_path: str, 

303 root: Path, 

304 gitignore_dict: dict[Path, GitIgnoreSpec], 

305) -> bool: 

306 path = root / root_relative_path 

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

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

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

310 try: 

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

312 if path.is_dir(): 

313 relative_path = relative_path + "/" 

314 except ValueError: 

315 break 

316 if pattern.match_file(relative_path): 

317 return True 

318 return False 

319 

320 

321def path_is_excluded( 

322 normalized_path: str, 

323 pattern: Pattern[str] | None, 

324) -> bool: 

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

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

327 

328 

329def gen_python_files( 

330 paths: Iterable[Path], 

331 root: Path, 

332 include: Pattern[str], 

333 exclude: Pattern[str], 

334 extend_exclude: Pattern[str] | None, 

335 force_exclude: Pattern[str] | None, 

336 report: Report, 

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

338 *, 

339 verbose: bool, 

340 quiet: bool, 

341) -> Iterator[Path]: 

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

343 `exclude_regex`, `extend_exclude`, or `force_exclude` regexes, 

344 but are included by the `include` regex. 

345 

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

347 

348 `report` is where output about exclusions goes. 

349 """ 

350 

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

352 for child in paths: 

353 assert child.is_absolute() 

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

355 

356 # First ignore files matching .gitignore, if passed 

357 if gitignore_dict and _path_is_ignored( 

358 root_relative_path, root, gitignore_dict 

359 ): 

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

361 continue 

362 

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

364 root_relative_path = "/" + root_relative_path 

365 if child.is_dir(): 

366 root_relative_path += "/" 

367 

368 if path_is_excluded(root_relative_path, exclude): 

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

370 continue 

371 

372 if path_is_excluded(root_relative_path, extend_exclude): 

373 report.path_ignored( 

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

375 ) 

376 continue 

377 

378 if path_is_excluded(root_relative_path, force_exclude): 

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

380 continue 

381 

382 if resolves_outside_root_or_cannot_stat(child, root, report): 

383 continue 

384 

385 if child.is_dir(): 

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

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

388 if gitignore_dict is not None: 

389 new_gitignore_dict = { 

390 **gitignore_dict, 

391 root / child: get_gitignore(child), 

392 } 

393 else: 

394 new_gitignore_dict = None 

395 yield from gen_python_files( 

396 child.iterdir(), 

397 root, 

398 include, 

399 exclude, 

400 extend_exclude, 

401 force_exclude, 

402 report, 

403 new_gitignore_dict, 

404 verbose=verbose, 

405 quiet=quiet, 

406 ) 

407 

408 elif child.is_file(): 

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

410 warn=verbose or not quiet 

411 ): 

412 continue 

413 include_match = include.search(root_relative_path) if include else True 

414 if include_match: 

415 yield child 

416 

417 

418def wrap_stream_for_windows( 

419 f: io.TextIOWrapper, 

420) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]: 

421 """ 

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

423 

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

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

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

427 an `AnsiToWin32` wrapper or the original stream. 

428 """ 

429 try: 

430 from colorama.initialise import wrap_stream 

431 except ImportError: 

432 return f 

433 else: 

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

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