--- /dev/null
+from functools import lru_cache
+import io
+import os
+from pathlib import Path
+import sys
+from typing import (
+ Any,
+ Dict,
+ Iterable,
+ Iterator,
+ List,
+ Optional,
+ Pattern,
+ Sequence,
+ Tuple,
+ Union,
+ TYPE_CHECKING,
+)
+
+from pathspec import PathSpec
+from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
+import tomli
+
+from black.output import err
+from black.report import Report
+from black.handle_ipynb_magics import jupyter_dependencies_are_installed
+
+if TYPE_CHECKING:
+ import colorama # noqa: F401
+
+
+@lru_cache()
+def find_project_root(srcs: Sequence[str]) -> Path:
+ """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.
+ """
+ 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
+
+ if (directory / ".hg").is_dir():
+ return directory
+
+ if (directory / "pyproject.toml").is_file():
+ return directory
+
+ return directory
+
+
+def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]:
+ """Find the absolute filepath to a pyproject.toml if it exists"""
+ path_project_root = find_project_root(path_search_start)
+ 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 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
+
+
+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 tomli.TOMLDecodeError
+ """
+ with open(path_config, encoding="utf8") as f:
+ pyproject_toml = tomli.load(f) # type: ignore # due to deprecated API usage
+ config = pyproject_toml.get("tool", {}).get("black", {})
+ return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
+
+
+@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.
+ """
+ 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: Report
+) -> 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().relative_to(root).as_posix()
+ except OSError as e:
+ report.path_ignored(path, f"cannot be read because {e}")
+ return None
+
+ except ValueError:
+ if path.is_symlink():
+ report.path_ignored(path, f"is a symbolic link that points outside {root}")
+ return None
+
+ raise
+
+ return normalized_path
+
+
+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: Optional[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:
+ normalized_path = normalize_path_maybe_ignore(child, root, report)
+ if normalized_path is None:
+ continue
+
+ # First ignore files matching .gitignore, if passed
+ if gitignore is not None and gitignore.match_file(normalized_path):
+ report.path_ignored(child, "matches the .gitignore file content")
+ continue
+
+ # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
+ normalized_path = "/" + normalized_path
+ if child.is_dir():
+ normalized_path += "/"
+
+ if path_is_excluded(normalized_path, exclude):
+ report.path_ignored(child, "matches the --exclude regular expression")
+ continue
+
+ if path_is_excluded(normalized_path, extend_exclude):
+ report.path_ignored(
+ child, "matches the --extend-exclude regular expression"
+ )
+ continue
+
+ if path_is_excluded(normalized_path, force_exclude):
+ report.path_ignored(child, "matches the --force-exclude regular expression")
+ 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.
+ yield from gen_python_files(
+ child.iterdir(),
+ root,
+ include,
+ exclude,
+ extend_exclude,
+ force_exclude,
+ report,
+ gitignore + get_gitignore(child) if gitignore is not None else None,
+ verbose=verbose,
+ quiet=quiet,
+ )
+
+ elif child.is_file():
+ if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed(
+ verbose=verbose, quiet=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)