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 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
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
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 # noqa: F401
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)
41@lru_cache
42def _cached_resolve(path: Path) -> Path:
43 return path.resolve()
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.
52 pyproject.toml files are only considered if they contain a [tool.black]
53 section and are ignored otherwise.
55 That directory will be a common parent of all files and directories
56 passed in `srcs`.
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.
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()))]
70 path_srcs = [_cached_resolve(Path(Path.cwd(), src)) for src in srcs]
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 ]
78 common_base = max(
79 set.intersection(*(set(parents) for parents in src_parents)),
80 key=lambda path: path.parts,
81 )
83 for directory in (common_base, *common_base.parents):
84 if (directory / ".git").exists():
85 return directory, ".git directory"
87 if (directory / ".hg").is_dir():
88 return directory, ".hg directory"
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"
95 return directory, "file system root"
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)
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
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.
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()}
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]
135 return config
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.
143 Supports the PyPA standard format (PEP 621):
144 https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
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
160 return None
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.
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
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.
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
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
195def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet:
196 """Strip minor versions for some specifiers in the specifier set.
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)
217 return SpecifierSet(",".join(str(s) for s in specifiers))
220@lru_cache
221def find_user_pyproject_toml() -> Path:
222 r"""Return the path to the top-level user configuration for black.
224 This looks for ~\.black on Windows and ~/.config/black on Linux and other
225 Unix systems.
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)
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
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
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)
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
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))
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.
337 Symbolic links pointing outside of the `root` directory are ignored.
339 `report` is where output about exclusions goes.
340 """
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()
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
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 += "/"
359 if path_is_excluded(root_relative_path, exclude):
360 report.path_ignored(child, "matches the --exclude regular expression")
361 continue
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
369 if path_is_excluded(root_relative_path, force_exclude):
370 report.path_ignored(child, "matches the --force-exclude regular expression")
371 continue
373 if resolves_outside_root_or_cannot_stat(child, root, report):
374 continue
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 )
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
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.
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)