import io import os import sys from functools import lru_cache from pathlib import Path from typing import ( TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional, Pattern, Sequence, Tuple, Union, ) from mypy_extensions import mypyc_attr from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet from packaging.version import InvalidVersion, Version from pathspec import PathSpec from pathspec.patterns.gitwildmatch import GitWildMatchPatternError if sys.version_info >= (3, 11): try: import tomllib except ImportError: # Help users on older alphas if not TYPE_CHECKING: import tomli as tomllib else: import tomli as tomllib from black.handle_ipynb_magics import jupyter_dependencies_are_installed from black.mode import TargetVersion from black.output import err from black.report import Report if TYPE_CHECKING: import colorama # noqa: F401 @lru_cache def find_project_root( srcs: Sequence[str], stdin_filename: Optional[str] = None ) -> Tuple[Path, str]: """Return a directory containing .git, .hg, or pyproject.toml. That directory will be a common parent of all files and directories passed in `srcs`. If no directory in the tree contains a marker that would specify it's the project root, the root of the file system is returned. Returns a two-tuple with the first element as the project root path and the second element as a string describing the method by which the project root was discovered. """ if stdin_filename is not None: srcs = tuple(stdin_filename if s == "-" else s for s in srcs) if not srcs: srcs = [str(Path.cwd().resolve())] path_srcs = [Path(Path.cwd(), src).resolve() for src in srcs] # A list of lists of parents for each 'src'. 'src' is included as a # "parent" of itself if it is a directory src_parents = [ list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs ] common_base = max( set.intersection(*(set(parents) for parents in src_parents)), key=lambda path: path.parts, ) for directory in (common_base, *common_base.parents): if (directory / ".git").exists(): return directory, ".git directory" if (directory / ".hg").is_dir(): return directory, ".hg directory" if (directory / "pyproject.toml").is_file(): return directory, "pyproject.toml" return directory, "file system root" def find_pyproject_toml( path_search_start: Tuple[str, ...], stdin_filename: Optional[str] = None ) -> Optional[str]: """Find the absolute filepath to a pyproject.toml if it exists""" path_project_root, _ = find_project_root(path_search_start, stdin_filename) path_pyproject_toml = path_project_root / "pyproject.toml" if path_pyproject_toml.is_file(): return str(path_pyproject_toml) try: path_user_pyproject_toml = find_user_pyproject_toml() return ( str(path_user_pyproject_toml) if path_user_pyproject_toml.is_file() else None ) except (PermissionError, RuntimeError) as e: # We do not have access to the user-level config directory, so ignore it. err(f"Ignoring user configuration directory due to {e!r}") return None @mypyc_attr(patchable=True) def parse_pyproject_toml(path_config: str) -> Dict[str, Any]: """Parse a pyproject toml file, pulling out relevant parts for Black. If parsing fails, will raise a tomllib.TOMLDecodeError. """ with open(path_config, "rb") as f: pyproject_toml = tomllib.load(f) config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {}) config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} if "target_version" not in config: inferred_target_version = infer_target_version(pyproject_toml) if inferred_target_version is not None: config["target_version"] = [v.name.lower() for v in inferred_target_version] return config def infer_target_version( pyproject_toml: Dict[str, Any] ) -> Optional[List[TargetVersion]]: """Infer Black's target version from the project metadata in pyproject.toml. Supports the PyPA standard format (PEP 621): https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python If the target version cannot be inferred, returns None. """ project_metadata = pyproject_toml.get("project", {}) requires_python = project_metadata.get("requires-python", None) if requires_python is not None: try: return parse_req_python_version(requires_python) except InvalidVersion: pass try: return parse_req_python_specifier(requires_python) except (InvalidSpecifier, InvalidVersion): pass return None def parse_req_python_version(requires_python: str) -> Optional[List[TargetVersion]]: """Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion. If parsing fails, will raise a packaging.version.InvalidVersion error. If the parsed version cannot be mapped to a valid TargetVersion, returns None. """ version = Version(requires_python) if version.release[0] != 3: return None try: return [TargetVersion(version.release[1])] except (IndexError, ValueError): return None def parse_req_python_specifier(requires_python: str) -> Optional[List[TargetVersion]]: """Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion. If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error. If the parsed specifier cannot be mapped to a valid TargetVersion, returns None. """ specifier_set = strip_specifier_set(SpecifierSet(requires_python)) if not specifier_set: return None target_version_map = {f"3.{v.value}": v for v in TargetVersion} compatible_versions: List[str] = list(specifier_set.filter(target_version_map)) if compatible_versions: return [target_version_map[v] for v in compatible_versions] return None def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet: """Strip minor versions for some specifiers in the specifier set. For background on version specifiers, see PEP 440: https://peps.python.org/pep-0440/#version-specifiers """ specifiers = [] for s in specifier_set: if "*" in str(s): specifiers.append(s) elif s.operator in ["~=", "==", ">=", "==="]: version = Version(s.version) stripped = Specifier(f"{s.operator}{version.major}.{version.minor}") specifiers.append(stripped) elif s.operator == ">": version = Version(s.version) if len(version.release) > 2: s = Specifier(f">={version.major}.{version.minor}") specifiers.append(s) else: specifiers.append(s) return SpecifierSet(",".join(str(s) for s in specifiers)) @lru_cache def find_user_pyproject_toml() -> Path: r"""Return the path to the top-level user configuration for black. This looks for ~\.black on Windows and ~/.config/black on Linux and other Unix systems. May raise: - RuntimeError: if the current user has no homedir - PermissionError: if the current process cannot access the user's homedir """ if sys.platform == "win32": # Windows user_config_path = Path.home() / ".black" else: config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config") user_config_path = Path(config_root).expanduser() / "black" return user_config_path.resolve() @lru_cache def get_gitignore(root: Path) -> PathSpec: """Return a PathSpec matching gitignore content if present.""" gitignore = root / ".gitignore" lines: List[str] = [] if gitignore.is_file(): with gitignore.open(encoding="utf-8") as gf: lines = gf.readlines() try: return PathSpec.from_lines("gitwildmatch", lines) except GitWildMatchPatternError as e: err(f"Could not parse {gitignore}: {e}") raise def normalize_path_maybe_ignore( path: Path, root: Path, report: Optional[Report] = None, ) -> Optional[str]: """Normalize `path`. May return `None` if `path` was ignored. `report` is where "path ignored" output goes. """ try: abspath = path if path.is_absolute() else Path.cwd() / path normalized_path = abspath.resolve() try: root_relative_path = normalized_path.relative_to(root).as_posix() except ValueError: if report: report.path_ignored( path, f"is a symbolic link that points outside {root}" ) return None except OSError as e: if report: report.path_ignored(path, f"cannot be read because {e}") return None return root_relative_path def _path_is_ignored( root_relative_path: str, root: Path, gitignore_dict: Dict[Path, PathSpec], report: Report, ) -> bool: path = root / root_relative_path # Note that this logic is sensitive to the ordering of gitignore_dict. Callers must # ensure that gitignore_dict is ordered from least specific to most specific. for gitignore_path, pattern in gitignore_dict.items(): try: relative_path = path.relative_to(gitignore_path).as_posix() except ValueError: break if pattern.match_file(relative_path): report.path_ignored( path.relative_to(root), "matches a .gitignore file content" ) return True return False def path_is_excluded( normalized_path: str, pattern: Optional[Pattern[str]], ) -> bool: match = pattern.search(normalized_path) if pattern else None return bool(match and match.group(0)) def gen_python_files( paths: Iterable[Path], root: Path, include: Pattern[str], exclude: Pattern[str], extend_exclude: Optional[Pattern[str]], force_exclude: Optional[Pattern[str]], report: Report, gitignore_dict: Optional[Dict[Path, PathSpec]], *, verbose: bool, quiet: bool, ) -> Iterator[Path]: """Generate all files under `path` whose paths are not excluded by the `exclude_regex`, `extend_exclude`, or `force_exclude` regexes, but are included by the `include` regex. Symbolic links pointing outside of the `root` directory are ignored. `report` is where output about exclusions goes. """ assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}" for child in paths: root_relative_path = child.absolute().relative_to(root).as_posix() # First ignore files matching .gitignore, if passed if gitignore_dict and _path_is_ignored( root_relative_path, root, gitignore_dict, report ): continue # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options. root_relative_path = "/" + root_relative_path if child.is_dir(): root_relative_path += "/" if path_is_excluded(root_relative_path, exclude): report.path_ignored(child, "matches the --exclude regular expression") continue if path_is_excluded(root_relative_path, extend_exclude): report.path_ignored( child, "matches the --extend-exclude regular expression" ) continue if path_is_excluded(root_relative_path, force_exclude): report.path_ignored(child, "matches the --force-exclude regular expression") continue normalized_path = normalize_path_maybe_ignore(child, root, report) if normalized_path is None: continue if child.is_dir(): # If gitignore is None, gitignore usage is disabled, while a Falsey # gitignore is when the directory doesn't have a .gitignore file. if gitignore_dict is not None: new_gitignore_dict = { **gitignore_dict, root / child: get_gitignore(child), } else: new_gitignore_dict = None yield from gen_python_files( child.iterdir(), root, include, exclude, extend_exclude, force_exclude, report, new_gitignore_dict, verbose=verbose, quiet=quiet, ) elif child.is_file(): if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed( warn=verbose or not quiet ): continue include_match = include.search(normalized_path) if include else True if include_match: yield child def wrap_stream_for_windows( f: io.TextIOWrapper, ) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]: """ Wrap stream with colorama's wrap_stream so colors are shown on Windows. If `colorama` is unavailable, the original stream is returned unmodified. Otherwise, the `wrap_stream()` function determines whether the stream needs to be wrapped for a Windows environment and will accordingly either return an `AnsiToWin32` wrapper or the original stream. """ try: from colorama.initialise import wrap_stream except ImportError: return f else: # Set `strip=False` to avoid needing to modify test_express_diff_with_color. return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)