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

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