]> git.madduck.net Git - etc/vim.git/blob - src/black/files.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

Github now supports .git-blame-ignore-revs (GH-2948)
[etc/vim.git] / src / black / files.py
1 from functools import lru_cache
2 import io
3 import os
4 from pathlib import Path
5 import sys
6 from typing import (
7     Any,
8     Dict,
9     Iterable,
10     Iterator,
11     List,
12     Optional,
13     Pattern,
14     Sequence,
15     Tuple,
16     Union,
17     TYPE_CHECKING,
18 )
19
20 from mypy_extensions import mypyc_attr
21 from pathspec import PathSpec
22 from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
23
24 if sys.version_info >= (3, 11):
25     import tomllib
26 else:
27     import tomli as tomllib
28
29 from black.output import err
30 from black.report import Report
31 from black.handle_ipynb_magics import jupyter_dependencies_are_installed
32
33 if TYPE_CHECKING:
34     import colorama  # noqa: F401
35
36
37 @lru_cache()
38 def find_project_root(srcs: Sequence[str]) -> Tuple[Path, str]:
39     """Return a directory containing .git, .hg, or pyproject.toml.
40
41     That directory will be a common parent of all files and directories
42     passed in `srcs`.
43
44     If no directory in the tree contains a marker that would specify it's the
45     project root, the root of the file system is returned.
46
47     Returns a two-tuple with the first element as the project root path and
48     the second element as a string describing the method by which the
49     project root was discovered.
50     """
51     if not srcs:
52         srcs = [str(Path.cwd().resolve())]
53
54     path_srcs = [Path(Path.cwd(), src).resolve() for src in srcs]
55
56     # A list of lists of parents for each 'src'. 'src' is included as a
57     # "parent" of itself if it is a directory
58     src_parents = [
59         list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs
60     ]
61
62     common_base = max(
63         set.intersection(*(set(parents) for parents in src_parents)),
64         key=lambda path: path.parts,
65     )
66
67     for directory in (common_base, *common_base.parents):
68         if (directory / ".git").exists():
69             return directory, ".git directory"
70
71         if (directory / ".hg").is_dir():
72             return directory, ".hg directory"
73
74         if (directory / "pyproject.toml").is_file():
75             return directory, "pyproject.toml"
76
77     return directory, "file system root"
78
79
80 def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]:
81     """Find the absolute filepath to a pyproject.toml if it exists"""
82     path_project_root, _ = find_project_root(path_search_start)
83     path_pyproject_toml = path_project_root / "pyproject.toml"
84     if path_pyproject_toml.is_file():
85         return str(path_pyproject_toml)
86
87     try:
88         path_user_pyproject_toml = find_user_pyproject_toml()
89         return (
90             str(path_user_pyproject_toml)
91             if path_user_pyproject_toml.is_file()
92             else None
93         )
94     except (PermissionError, RuntimeError) as e:
95         # We do not have access to the user-level config directory, so ignore it.
96         err(f"Ignoring user configuration directory due to {e!r}")
97         return None
98
99
100 @mypyc_attr(patchable=True)
101 def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
102     """Parse a pyproject toml file, pulling out relevant parts for Black
103
104     If parsing fails, will raise a tomllib.TOMLDecodeError
105     """
106     with open(path_config, "rb") as f:
107         pyproject_toml = tomllib.load(f)
108     config = pyproject_toml.get("tool", {}).get("black", {})
109     return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
110
111
112 @lru_cache()
113 def find_user_pyproject_toml() -> Path:
114     r"""Return the path to the top-level user configuration for black.
115
116     This looks for ~\.black on Windows and ~/.config/black on Linux and other
117     Unix systems.
118
119     May raise:
120     - RuntimeError: if the current user has no homedir
121     - PermissionError: if the current process cannot access the user's homedir
122     """
123     if sys.platform == "win32":
124         # Windows
125         user_config_path = Path.home() / ".black"
126     else:
127         config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config")
128         user_config_path = Path(config_root).expanduser() / "black"
129     return user_config_path.resolve()
130
131
132 @lru_cache()
133 def get_gitignore(root: Path) -> PathSpec:
134     """Return a PathSpec matching gitignore content if present."""
135     gitignore = root / ".gitignore"
136     lines: List[str] = []
137     if gitignore.is_file():
138         with gitignore.open(encoding="utf-8") as gf:
139             lines = gf.readlines()
140     try:
141         return PathSpec.from_lines("gitwildmatch", lines)
142     except GitWildMatchPatternError as e:
143         err(f"Could not parse {gitignore}: {e}")
144         raise
145
146
147 def normalize_path_maybe_ignore(
148     path: Path,
149     root: Path,
150     report: Optional[Report] = None,
151 ) -> Optional[str]:
152     """Normalize `path`. May return `None` if `path` was ignored.
153
154     `report` is where "path ignored" output goes.
155     """
156     try:
157         abspath = path if path.is_absolute() else Path.cwd() / path
158         normalized_path = abspath.resolve()
159         try:
160             root_relative_path = normalized_path.relative_to(root).as_posix()
161         except ValueError:
162             if report:
163                 report.path_ignored(
164                     path, f"is a symbolic link that points outside {root}"
165                 )
166             return None
167
168     except OSError as e:
169         if report:
170             report.path_ignored(path, f"cannot be read because {e}")
171         return None
172
173     return root_relative_path
174
175
176 def path_is_excluded(
177     normalized_path: str,
178     pattern: Optional[Pattern[str]],
179 ) -> bool:
180     match = pattern.search(normalized_path) if pattern else None
181     return bool(match and match.group(0))
182
183
184 def gen_python_files(
185     paths: Iterable[Path],
186     root: Path,
187     include: Pattern[str],
188     exclude: Pattern[str],
189     extend_exclude: Optional[Pattern[str]],
190     force_exclude: Optional[Pattern[str]],
191     report: Report,
192     gitignore: Optional[PathSpec],
193     *,
194     verbose: bool,
195     quiet: bool,
196 ) -> Iterator[Path]:
197     """Generate all files under `path` whose paths are not excluded by the
198     `exclude_regex`, `extend_exclude`, or `force_exclude` regexes,
199     but are included by the `include` regex.
200
201     Symbolic links pointing outside of the `root` directory are ignored.
202
203     `report` is where output about exclusions goes.
204     """
205     assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
206     for child in paths:
207         normalized_path = normalize_path_maybe_ignore(child, root, report)
208         if normalized_path is None:
209             continue
210
211         # First ignore files matching .gitignore, if passed
212         if gitignore is not None and gitignore.match_file(normalized_path):
213             report.path_ignored(child, "matches the .gitignore file content")
214             continue
215
216         # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
217         normalized_path = "/" + normalized_path
218         if child.is_dir():
219             normalized_path += "/"
220
221         if path_is_excluded(normalized_path, exclude):
222             report.path_ignored(child, "matches the --exclude regular expression")
223             continue
224
225         if path_is_excluded(normalized_path, extend_exclude):
226             report.path_ignored(
227                 child, "matches the --extend-exclude regular expression"
228             )
229             continue
230
231         if path_is_excluded(normalized_path, force_exclude):
232             report.path_ignored(child, "matches the --force-exclude regular expression")
233             continue
234
235         if child.is_dir():
236             # If gitignore is None, gitignore usage is disabled, while a Falsey
237             # gitignore is when the directory doesn't have a .gitignore file.
238             yield from gen_python_files(
239                 child.iterdir(),
240                 root,
241                 include,
242                 exclude,
243                 extend_exclude,
244                 force_exclude,
245                 report,
246                 gitignore + get_gitignore(child) if gitignore is not None else None,
247                 verbose=verbose,
248                 quiet=quiet,
249             )
250
251         elif child.is_file():
252             if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed(
253                 verbose=verbose, quiet=quiet
254             ):
255                 continue
256             include_match = include.search(normalized_path) if include else True
257             if include_match:
258                 yield child
259
260
261 def wrap_stream_for_windows(
262     f: io.TextIOWrapper,
263 ) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]:
264     """
265     Wrap stream with colorama's wrap_stream so colors are shown on Windows.
266
267     If `colorama` is unavailable, the original stream is returned unmodified.
268     Otherwise, the `wrap_stream()` function determines whether the stream needs
269     to be wrapped for a Windows environment and will accordingly either return
270     an `AnsiToWin32` wrapper or the original stream.
271     """
272     try:
273         from colorama.initialise import wrap_stream
274     except ImportError:
275         return f
276     else:
277         # Set `strip=False` to avoid needing to modify test_express_diff_with_color.
278         return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)