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
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 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
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
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
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
31if TYPE_CHECKING:
32 import colorama
35@lru_cache
36def _load_toml(path: Path | str) -> dict[str, Any]:
37 with open(path, "rb") as f:
38 return tomllib.load(f)
41@lru_cache
42def _cached_resolve(path: Path) -> Path:
43 return path.resolve()
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.
51 pyproject.toml files are only considered if they contain a [tool.black]
52 section and are ignored otherwise.
54 That directory will be a common parent of all files and directories
55 passed in `srcs`.
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.
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)
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 )
74 return _find_project_root_cached(resolved_srcs)
77@lru_cache
78def _find_project_root_cached(srcs: tuple[str, ...]) -> tuple[Path, str]:
79 path_srcs = [Path(src) for src in srcs]
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 ]
87 common_base = max(
88 set.intersection(*(set(parents) for parents in src_parents)),
89 key=lambda path: path.parts,
90 )
92 for directory in (common_base, *common_base.parents):
93 if (directory / ".git").exists():
94 return directory, ".git directory"
96 if (directory / ".hg").is_dir():
97 return directory, ".hg directory"
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"
104 return directory, "file system root"
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)
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
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.
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()}
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]
144 return config
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.
152 Supports the PyPA standard format (PEP 621):
153 https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
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
169 return None
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.
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
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.
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
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
204def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet:
205 """Strip minor versions for some specifiers in the specifier set.
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)
226 return SpecifierSet(",".join(str(s) for s in specifiers))
229@lru_cache
230def find_user_pyproject_toml() -> Path:
231 r"""Return the path to the top-level user configuration for black.
233 This looks for ~\.black on Windows and ~/.config/black on Linux and other
234 Unix systems.
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)
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
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
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)
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
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))
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.
346 Symbolic links pointing outside of the `root` directory are ignored.
348 `report` is where output about exclusions goes.
349 """
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()
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
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 += "/"
368 if path_is_excluded(root_relative_path, exclude):
369 report.path_ignored(child, "matches the --exclude regular expression")
370 continue
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
378 if path_is_excluded(root_relative_path, force_exclude):
379 report.path_ignored(child, "matches the --force-exclude regular expression")
380 continue
382 if resolves_outside_root_or_cannot_stat(child, root, report):
383 continue
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 )
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
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.
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)