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

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