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
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
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
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
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
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
30if TYPE_CHECKING:
31 import colorama
34@lru_cache
35def _load_toml(path: Path | str) -> dict[str, Any]:
36 with open(path, "rb") as f:
37 return tomllib.load(f)
40@lru_cache
41def _cached_resolve(path: Path) -> Path:
42 return path.resolve()
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.
50 pyproject.toml files are only considered if they contain a [tool.black]
51 section and are ignored otherwise.
53 That directory will be a common parent of all files and directories
54 passed in `srcs`.
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.
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.
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)
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 )
77 return _find_project_root_cached(resolved_srcs)
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]
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 ]
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)
95 for directory in (common_base, *common_base.parents):
96 if (directory / ".git").exists():
97 return directory, ".git directory"
99 if (directory / ".hg").is_dir():
100 return directory, ".hg directory"
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"
107 return directory, "file system root"
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)
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
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.
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()}
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]
150 return config
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.
158 Supports the PyPA standard format (PEP 621):
159 https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
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
175 return None
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.
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
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.
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
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
210def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet:
211 """Strip minor versions for some specifiers in the specifier set.
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)
232 return SpecifierSet(",".join(str(s) for s in specifiers))
235@lru_cache
236def find_user_pyproject_toml() -> Path:
237 r"""Return the path to the top-level user configuration for black.
239 This looks for ~\.black on Windows and ~/.config/black on Linux and other
240 Unix systems.
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)
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
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
298def _collapse_parent_dirs(relative_path: Path) -> Path:
299 """Collapse ".." in a root-relative path without resolving symlinks.
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
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)
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
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))
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.
369 Symbolic links pointing outside of the `root` directory are ignored.
371 `report` is where output about exclusions goes.
372 """
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()
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
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 += "/"
391 if path_is_excluded(root_relative_path, exclude):
392 report.path_ignored(child, "matches the --exclude regular expression")
393 continue
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
401 if path_is_excluded(root_relative_path, force_exclude):
402 report.path_ignored(child, "matches the --force-exclude regular expression")
403 continue
405 if resolves_outside_root_or_cannot_stat(child, root, report):
406 continue
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 )
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
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.
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)