]> 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 handling of Windows junctions in normalize_path_maybe_ignore (#2904)
[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()
155         try:
156             root_relative_path = normalized_path.relative_to(root).as_posix()
157         except ValueError:
158             if report:
159                 report.path_ignored(
160                     path, f"is a symbolic link that points outside {root}"
161                 )
162             return None
163
164     except OSError as e:
165         if report:
166             report.path_ignored(path, f"cannot be read because {e}")
167         return None
168
169     return root_relative_path
170
171
172 def path_is_excluded(
173     normalized_path: str,
174     pattern: Optional[Pattern[str]],
175 ) -> bool:
176     match = pattern.search(normalized_path) if pattern else None
177     return bool(match and match.group(0))
178
179
180 def gen_python_files(
181     paths: Iterable[Path],
182     root: Path,
183     include: Pattern[str],
184     exclude: Pattern[str],
185     extend_exclude: Optional[Pattern[str]],
186     force_exclude: Optional[Pattern[str]],
187     report: Report,
188     gitignore: Optional[PathSpec],
189     *,
190     verbose: bool,
191     quiet: bool,
192 ) -> Iterator[Path]:
193     """Generate all files under `path` whose paths are not excluded by the
194     `exclude_regex`, `extend_exclude`, or `force_exclude` regexes,
195     but are included by the `include` regex.
196
197     Symbolic links pointing outside of the `root` directory are ignored.
198
199     `report` is where output about exclusions goes.
200     """
201     assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
202     for child in paths:
203         normalized_path = normalize_path_maybe_ignore(child, root, report)
204         if normalized_path is None:
205             continue
206
207         # First ignore files matching .gitignore, if passed
208         if gitignore is not None and gitignore.match_file(normalized_path):
209             report.path_ignored(child, "matches the .gitignore file content")
210             continue
211
212         # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
213         normalized_path = "/" + normalized_path
214         if child.is_dir():
215             normalized_path += "/"
216
217         if path_is_excluded(normalized_path, exclude):
218             report.path_ignored(child, "matches the --exclude regular expression")
219             continue
220
221         if path_is_excluded(normalized_path, extend_exclude):
222             report.path_ignored(
223                 child, "matches the --extend-exclude regular expression"
224             )
225             continue
226
227         if path_is_excluded(normalized_path, force_exclude):
228             report.path_ignored(child, "matches the --force-exclude regular expression")
229             continue
230
231         if child.is_dir():
232             # If gitignore is None, gitignore usage is disabled, while a Falsey
233             # gitignore is when the directory doesn't have a .gitignore file.
234             yield from gen_python_files(
235                 child.iterdir(),
236                 root,
237                 include,
238                 exclude,
239                 extend_exclude,
240                 force_exclude,
241                 report,
242                 gitignore + get_gitignore(child) if gitignore is not None else None,
243                 verbose=verbose,
244                 quiet=quiet,
245             )
246
247         elif child.is_file():
248             if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed(
249                 verbose=verbose, quiet=quiet
250             ):
251                 continue
252             include_match = include.search(normalized_path) if include else True
253             if include_match:
254                 yield child
255
256
257 def wrap_stream_for_windows(
258     f: io.TextIOWrapper,
259 ) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]:
260     """
261     Wrap stream with colorama's wrap_stream so colors are shown on Windows.
262
263     If `colorama` is unavailable, the original stream is returned unmodified.
264     Otherwise, the `wrap_stream()` function determines whether the stream needs
265     to be wrapped for a Windows environment and will accordingly either return
266     an `AnsiToWin32` wrapper or the original stream.
267     """
268     try:
269         from colorama.initialise import wrap_stream
270     except ImportError:
271         return f
272     else:
273         # Set `strip=False` to avoid needing to modify test_express_diff_with_color.
274         return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)