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

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