All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
4 from functools import lru_cache
5 from pathlib import Path
20 from mypy_extensions import mypyc_attr
21 from pathspec import PathSpec
22 from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
24 if sys.version_info >= (3, 11):
28 # Help users on older alphas
30 import tomli as tomllib
32 import tomli as tomllib
34 from black.handle_ipynb_magics import jupyter_dependencies_are_installed
35 from black.output import err
36 from black.report import Report
39 import colorama # noqa: F401
43 def find_project_root(
44 srcs: Sequence[str], stdin_filename: Optional[str] = None
45 ) -> Tuple[Path, str]:
46 """Return a directory containing .git, .hg, or pyproject.toml.
48 That directory will be a common parent of all files and directories
51 If no directory in the tree contains a marker that would specify it's the
52 project root, the root of the file system is returned.
54 Returns a two-tuple with the first element as the project root path and
55 the second element as a string describing the method by which the
56 project root was discovered.
58 if stdin_filename is not None:
59 srcs = tuple(stdin_filename if s == "-" else s for s in srcs)
61 srcs = [str(Path.cwd().resolve())]
63 path_srcs = [Path(Path.cwd(), src).resolve() for src in srcs]
65 # A list of lists of parents for each 'src'. 'src' is included as a
66 # "parent" of itself if it is a directory
68 list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs
72 set.intersection(*(set(parents) for parents in src_parents)),
73 key=lambda path: path.parts,
76 for directory in (common_base, *common_base.parents):
77 if (directory / ".git").exists():
78 return directory, ".git directory"
80 if (directory / ".hg").is_dir():
81 return directory, ".hg directory"
83 if (directory / "pyproject.toml").is_file():
84 return directory, "pyproject.toml"
86 return directory, "file system root"
89 def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]:
90 """Find the absolute filepath to a pyproject.toml if it exists"""
91 path_project_root, _ = find_project_root(path_search_start)
92 path_pyproject_toml = path_project_root / "pyproject.toml"
93 if path_pyproject_toml.is_file():
94 return str(path_pyproject_toml)
97 path_user_pyproject_toml = find_user_pyproject_toml()
99 str(path_user_pyproject_toml)
100 if path_user_pyproject_toml.is_file()
103 except (PermissionError, RuntimeError) as e:
104 # We do not have access to the user-level config directory, so ignore it.
105 err(f"Ignoring user configuration directory due to {e!r}")
109 @mypyc_attr(patchable=True)
110 def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
111 """Parse a pyproject toml file, pulling out relevant parts for Black
113 If parsing fails, will raise a tomllib.TOMLDecodeError
115 with open(path_config, "rb") as f:
116 pyproject_toml = tomllib.load(f)
117 config = pyproject_toml.get("tool", {}).get("black", {})
118 return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
122 def find_user_pyproject_toml() -> Path:
123 r"""Return the path to the top-level user configuration for black.
125 This looks for ~\.black on Windows and ~/.config/black on Linux and other
129 - RuntimeError: if the current user has no homedir
130 - PermissionError: if the current process cannot access the user's homedir
132 if sys.platform == "win32":
134 user_config_path = Path.home() / ".black"
136 config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config")
137 user_config_path = Path(config_root).expanduser() / "black"
138 return user_config_path.resolve()
142 def get_gitignore(root: Path) -> PathSpec:
143 """Return a PathSpec matching gitignore content if present."""
144 gitignore = root / ".gitignore"
145 lines: List[str] = []
146 if gitignore.is_file():
147 with gitignore.open(encoding="utf-8") as gf:
148 lines = gf.readlines()
150 return PathSpec.from_lines("gitwildmatch", lines)
151 except GitWildMatchPatternError as e:
152 err(f"Could not parse {gitignore}: {e}")
156 def normalize_path_maybe_ignore(
159 report: Optional[Report] = None,
161 """Normalize `path`. May return `None` if `path` was ignored.
163 `report` is where "path ignored" output goes.
166 abspath = path if path.is_absolute() else Path.cwd() / path
167 normalized_path = abspath.resolve()
169 root_relative_path = normalized_path.relative_to(root).as_posix()
173 path, f"is a symbolic link that points outside {root}"
179 report.path_ignored(path, f"cannot be read because {e}")
182 return root_relative_path
185 def path_is_excluded(
186 normalized_path: str,
187 pattern: Optional[Pattern[str]],
189 match = pattern.search(normalized_path) if pattern else None
190 return bool(match and match.group(0))
193 def gen_python_files(
194 paths: Iterable[Path],
196 include: Pattern[str],
197 exclude: Pattern[str],
198 extend_exclude: Optional[Pattern[str]],
199 force_exclude: Optional[Pattern[str]],
201 gitignore: Optional[PathSpec],
206 """Generate all files under `path` whose paths are not excluded by the
207 `exclude_regex`, `extend_exclude`, or `force_exclude` regexes,
208 but are included by the `include` regex.
210 Symbolic links pointing outside of the `root` directory are ignored.
212 `report` is where output about exclusions goes.
214 assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
216 normalized_path = normalize_path_maybe_ignore(child, root, report)
217 if normalized_path is None:
220 # First ignore files matching .gitignore, if passed
221 if gitignore is not None and gitignore.match_file(normalized_path):
222 report.path_ignored(child, "matches the .gitignore file content")
225 # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
226 normalized_path = "/" + normalized_path
228 normalized_path += "/"
230 if path_is_excluded(normalized_path, exclude):
231 report.path_ignored(child, "matches the --exclude regular expression")
234 if path_is_excluded(normalized_path, extend_exclude):
236 child, "matches the --extend-exclude regular expression"
240 if path_is_excluded(normalized_path, force_exclude):
241 report.path_ignored(child, "matches the --force-exclude regular expression")
245 # If gitignore is None, gitignore usage is disabled, while a Falsey
246 # gitignore is when the directory doesn't have a .gitignore file.
247 yield from gen_python_files(
255 gitignore + get_gitignore(child) if gitignore is not None else None,
260 elif child.is_file():
261 if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed(
262 verbose=verbose, quiet=quiet
265 include_match = include.search(normalized_path) if include else True
270 def wrap_stream_for_windows(
272 ) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]:
274 Wrap stream with colorama's wrap_stream so colors are shown on Windows.
276 If `colorama` is unavailable, the original stream is returned unmodified.
277 Otherwise, the `wrap_stream()` function determines whether the stream needs
278 to be wrapped for a Windows environment and will accordingly either return
279 an `AnsiToWin32` wrapper or the original stream.
282 from colorama.initialise import wrap_stream
286 # Set `strip=False` to avoid needing to modify test_express_diff_with_color.
287 return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)