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

215 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, Optional, Union 

9 

10from mypy_extensions import mypyc_attr 

11from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet 

12from packaging.version import InvalidVersion, Version 

13from pathspec import PathSpec 

14from pathspec.patterns.gitwildmatch import GitWildMatchPatternError 

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 # noqa: F401 

33 

34 

35@lru_cache 

36def _load_toml(path: Union[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 

46@lru_cache 

47def find_project_root( 

48 srcs: Sequence[str], stdin_filename: Optional[str] = None 

49) -> tuple[Path, str]: 

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

51 

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

53 section and are ignored otherwise. 

54 

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

56 passed in `srcs`. 

57 

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

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

60 

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

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

63 project root was discovered. 

64 """ 

65 if stdin_filename is not None: 

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

67 if not srcs: 

68 srcs = [str(_cached_resolve(Path.cwd()))] 

69 

70 path_srcs = [_cached_resolve(Path(Path.cwd(), src)) for src in srcs] 

71 

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

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

74 src_parents = [ 

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

76 ] 

77 

78 common_base = max( 

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

80 key=lambda path: path.parts, 

81 ) 

82 

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

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

85 return directory, ".git directory" 

86 

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

88 return directory, ".hg directory" 

89 

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

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

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

93 return directory, "pyproject.toml" 

94 

95 return directory, "file system root" 

96 

97 

98def find_pyproject_toml( 

99 path_search_start: tuple[str, ...], stdin_filename: Optional[str] = None 

100) -> Optional[str]: 

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

102 path_project_root, _ = find_project_root(path_search_start, stdin_filename) 

103 path_pyproject_toml = path_project_root / "pyproject.toml" 

104 if path_pyproject_toml.is_file(): 

105 return str(path_pyproject_toml) 

106 

107 try: 

108 path_user_pyproject_toml = find_user_pyproject_toml() 

109 return ( 

110 str(path_user_pyproject_toml) 

111 if path_user_pyproject_toml.is_file() 

112 else None 

113 ) 

114 except (PermissionError, RuntimeError) as e: 

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

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

117 return None 

118 

119 

120@mypyc_attr(patchable=True) 

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

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

123 

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

125 """ 

126 pyproject_toml = _load_toml(path_config) 

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

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

129 

130 if "target_version" not in config: 

131 inferred_target_version = infer_target_version(pyproject_toml) 

132 if inferred_target_version is not None: 

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

134 

135 return config 

136 

137 

138def infer_target_version( 

139 pyproject_toml: dict[str, Any], 

140) -> Optional[list[TargetVersion]]: 

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

142 

143 Supports the PyPA standard format (PEP 621): 

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

145 

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

147 """ 

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

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

150 if requires_python is not None: 

151 try: 

152 return parse_req_python_version(requires_python) 

153 except InvalidVersion: 

154 pass 

155 try: 

156 return parse_req_python_specifier(requires_python) 

157 except (InvalidSpecifier, InvalidVersion): 

158 pass 

159 

160 return None 

161 

162 

163def parse_req_python_version(requires_python: str) -> Optional[list[TargetVersion]]: 

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

165 

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

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

168 """ 

169 version = Version(requires_python) 

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

171 return None 

172 try: 

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

174 except (IndexError, ValueError): 

175 return None 

176 

177 

178def parse_req_python_specifier(requires_python: str) -> Optional[list[TargetVersion]]: 

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

180 

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

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

183 """ 

184 specifier_set = strip_specifier_set(SpecifierSet(requires_python)) 

185 if not specifier_set: 

186 return None 

187 

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

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

190 if compatible_versions: 

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

192 return None 

193 

194 

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

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

197 

198 For background on version specifiers, see PEP 440: 

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

200 """ 

201 specifiers = [] 

202 for s in specifier_set: 

203 if "*" in str(s): 

204 specifiers.append(s) 

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

206 version = Version(s.version) 

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

208 specifiers.append(stripped) 

209 elif s.operator == ">": 

210 version = Version(s.version) 

211 if len(version.release) > 2: 

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

213 specifiers.append(s) 

214 else: 

215 specifiers.append(s) 

216 

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

218 

219 

220@lru_cache 

221def find_user_pyproject_toml() -> Path: 

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

223 

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

225 Unix systems. 

226 

227 May raise: 

228 - RuntimeError: if the current user has no homedir 

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

230 """ 

231 if sys.platform == "win32": 

232 # Windows 

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

234 else: 

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

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

237 return _cached_resolve(user_config_path) 

238 

239 

240@lru_cache 

241def get_gitignore(root: Path) -> PathSpec: 

242 """Return a PathSpec matching gitignore content if present.""" 

243 gitignore = root / ".gitignore" 

244 lines: list[str] = [] 

245 if gitignore.is_file(): 

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

247 lines = gf.readlines() 

248 try: 

249 return PathSpec.from_lines("gitwildmatch", lines) 

250 except GitWildMatchPatternError as e: 

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

252 raise 

253 

254 

255def resolves_outside_root_or_cannot_stat( 

256 path: Path, 

257 root: Path, 

258 report: Optional[Report] = None, 

259) -> bool: 

260 """ 

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

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

263 """ 

264 try: 

265 resolved_path = _cached_resolve(path) 

266 except OSError as e: 

267 if report: 

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

269 return True 

270 try: 

271 resolved_path.relative_to(root) 

272 except ValueError: 

273 if report: 

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

275 return True 

276 return False 

277 

278 

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

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

281 try: 

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

283 except ValueError: 

284 pass 

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

286 if root_parent is not None: 

287 return path.relative_to(root_parent) 

288 # something adversarial, fallback to path guaranteed by precondition 

289 return _cached_resolve(path).relative_to(root) 

290 

291 

292def _path_is_ignored( 

293 root_relative_path: str, 

294 root: Path, 

295 gitignore_dict: dict[Path, PathSpec], 

296) -> bool: 

297 path = root / root_relative_path 

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

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

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

301 try: 

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

303 if path.is_dir(): 

304 relative_path = relative_path + "/" 

305 except ValueError: 

306 break 

307 if pattern.match_file(relative_path): 

308 return True 

309 return False 

310 

311 

312def path_is_excluded( 

313 normalized_path: str, 

314 pattern: Optional[Pattern[str]], 

315) -> bool: 

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

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

318 

319 

320def gen_python_files( 

321 paths: Iterable[Path], 

322 root: Path, 

323 include: Pattern[str], 

324 exclude: Pattern[str], 

325 extend_exclude: Optional[Pattern[str]], 

326 force_exclude: Optional[Pattern[str]], 

327 report: Report, 

328 gitignore_dict: Optional[dict[Path, PathSpec]], 

329 *, 

330 verbose: bool, 

331 quiet: bool, 

332) -> Iterator[Path]: 

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

334 `exclude_regex`, `extend_exclude`, or `force_exclude` regexes, 

335 but are included by the `include` regex. 

336 

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

338 

339 `report` is where output about exclusions goes. 

340 """ 

341 

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

343 for child in paths: 

344 assert child.is_absolute() 

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

346 

347 # First ignore files matching .gitignore, if passed 

348 if gitignore_dict and _path_is_ignored( 

349 root_relative_path, root, gitignore_dict 

350 ): 

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

352 continue 

353 

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

355 root_relative_path = "/" + root_relative_path 

356 if child.is_dir(): 

357 root_relative_path += "/" 

358 

359 if path_is_excluded(root_relative_path, exclude): 

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

361 continue 

362 

363 if path_is_excluded(root_relative_path, extend_exclude): 

364 report.path_ignored( 

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

366 ) 

367 continue 

368 

369 if path_is_excluded(root_relative_path, force_exclude): 

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

371 continue 

372 

373 if resolves_outside_root_or_cannot_stat(child, root, report): 

374 continue 

375 

376 if child.is_dir(): 

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

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

379 if gitignore_dict is not None: 

380 new_gitignore_dict = { 

381 **gitignore_dict, 

382 root / child: get_gitignore(child), 

383 } 

384 else: 

385 new_gitignore_dict = None 

386 yield from gen_python_files( 

387 child.iterdir(), 

388 root, 

389 include, 

390 exclude, 

391 extend_exclude, 

392 force_exclude, 

393 report, 

394 new_gitignore_dict, 

395 verbose=verbose, 

396 quiet=quiet, 

397 ) 

398 

399 elif child.is_file(): 

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

401 warn=verbose or not quiet 

402 ): 

403 continue 

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

405 if include_match: 

406 yield child 

407 

408 

409def wrap_stream_for_windows( 

410 f: io.TextIOWrapper, 

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

412 """ 

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

414 

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

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

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

418 an `AnsiToWin32` wrapper or the original stream. 

419 """ 

420 try: 

421 from colorama.initialise import wrap_stream 

422 except ImportError: 

423 return f 

424 else: 

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

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