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

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