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

[trivial] Use proper test cases on `unittest` (#2775)
[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 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     if sys.platform == "win32":
116         # Windows
117         user_config_path = Path.home() / ".black"
118     else:
119         config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config")
120         user_config_path = Path(config_root).expanduser() / "black"
121     return user_config_path.resolve()
122
123
124 @lru_cache()
125 def get_gitignore(root: Path) -> PathSpec:
126     """Return a PathSpec matching gitignore content if present."""
127     gitignore = root / ".gitignore"
128     lines: List[str] = []
129     if gitignore.is_file():
130         with gitignore.open(encoding="utf-8") as gf:
131             lines = gf.readlines()
132     try:
133         return PathSpec.from_lines("gitwildmatch", lines)
134     except GitWildMatchPatternError as e:
135         err(f"Could not parse {gitignore}: {e}")
136         raise
137
138
139 def normalize_path_maybe_ignore(
140     path: Path,
141     root: Path,
142     report: Optional[Report] = None,
143 ) -> Optional[str]:
144     """Normalize `path`. May return `None` if `path` was ignored.
145
146     `report` is where "path ignored" output goes.
147     """
148     try:
149         abspath = path if path.is_absolute() else Path.cwd() / path
150         normalized_path = abspath.resolve().relative_to(root).as_posix()
151     except OSError as e:
152         if report:
153             report.path_ignored(path, f"cannot be read because {e}")
154         return None
155
156     except ValueError:
157         if path.is_symlink():
158             if report:
159                 report.path_ignored(
160                     path, f"is a symbolic link that points outside {root}"
161                 )
162             return None
163
164         raise
165
166     return normalized_path
167
168
169 def path_is_excluded(
170     normalized_path: str,
171     pattern: Optional[Pattern[str]],
172 ) -> bool:
173     match = pattern.search(normalized_path) if pattern else None
174     return bool(match and match.group(0))
175
176
177 def gen_python_files(
178     paths: Iterable[Path],
179     root: Path,
180     include: Pattern[str],
181     exclude: Pattern[str],
182     extend_exclude: Optional[Pattern[str]],
183     force_exclude: Optional[Pattern[str]],
184     report: Report,
185     gitignore: Optional[PathSpec],
186     *,
187     verbose: bool,
188     quiet: bool,
189 ) -> Iterator[Path]:
190     """Generate all files under `path` whose paths are not excluded by the
191     `exclude_regex`, `extend_exclude`, or `force_exclude` regexes,
192     but are included by the `include` regex.
193
194     Symbolic links pointing outside of the `root` directory are ignored.
195
196     `report` is where output about exclusions goes.
197     """
198     assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
199     for child in paths:
200         normalized_path = normalize_path_maybe_ignore(child, root, report)
201         if normalized_path is None:
202             continue
203
204         # First ignore files matching .gitignore, if passed
205         if gitignore is not None and gitignore.match_file(normalized_path):
206             report.path_ignored(child, "matches the .gitignore file content")
207             continue
208
209         # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
210         normalized_path = "/" + normalized_path
211         if child.is_dir():
212             normalized_path += "/"
213
214         if path_is_excluded(normalized_path, exclude):
215             report.path_ignored(child, "matches the --exclude regular expression")
216             continue
217
218         if path_is_excluded(normalized_path, extend_exclude):
219             report.path_ignored(
220                 child, "matches the --extend-exclude regular expression"
221             )
222             continue
223
224         if path_is_excluded(normalized_path, force_exclude):
225             report.path_ignored(child, "matches the --force-exclude regular expression")
226             continue
227
228         if child.is_dir():
229             # If gitignore is None, gitignore usage is disabled, while a Falsey
230             # gitignore is when the directory doesn't have a .gitignore file.
231             yield from gen_python_files(
232                 child.iterdir(),
233                 root,
234                 include,
235                 exclude,
236                 extend_exclude,
237                 force_exclude,
238                 report,
239                 gitignore + get_gitignore(child) if gitignore is not None else None,
240                 verbose=verbose,
241                 quiet=quiet,
242             )
243
244         elif child.is_file():
245             if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed(
246                 verbose=verbose, quiet=quiet
247             ):
248                 continue
249             include_match = include.search(normalized_path) if include else True
250             if include_match:
251                 yield child
252
253
254 def wrap_stream_for_windows(
255     f: io.TextIOWrapper,
256 ) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]:
257     """
258     Wrap stream with colorama's wrap_stream so colors are shown on Windows.
259
260     If `colorama` is unavailable, the original stream is returned unmodified.
261     Otherwise, the `wrap_stream()` function determines whether the stream needs
262     to be wrapped for a Windows environment and will accordingly either return
263     an `AnsiToWin32` wrapper or the original stream.
264     """
265     try:
266         from colorama.initialise import wrap_stream
267     except ImportError:
268         return f
269     else:
270         # Set `strip=False` to avoid needing to modify test_express_diff_with_color.
271         return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)