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

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