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

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