]> 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:

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