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

Switch `toml` TOML library for `tomli` (#2301)
[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
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         srcs = [str(Path.cwd().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 tomli.TOMLDecodeError
93     """
94     with open(path_config, encoding="utf8") as f:
95         pyproject_toml = tomli.load(f)
96     config = pyproject_toml.get("tool", {}).get("black", {})
97     return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
98
99
100 @lru_cache()
101 def find_user_pyproject_toml() -> Path:
102     r"""Return the path to the top-level user configuration for black.
103
104     This looks for ~\.black on Windows and ~/.config/black on Linux and other
105     Unix systems.
106     """
107     if sys.platform == "win32":
108         # Windows
109         user_config_path = Path.home() / ".black"
110     else:
111         config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config")
112         user_config_path = Path(config_root).expanduser() / "black"
113     return user_config_path.resolve()
114
115
116 @lru_cache()
117 def get_gitignore(root: Path) -> PathSpec:
118     """Return a PathSpec matching gitignore content if present."""
119     gitignore = root / ".gitignore"
120     lines: List[str] = []
121     if gitignore.is_file():
122         with gitignore.open(encoding="utf-8") as gf:
123             lines = gf.readlines()
124     return PathSpec.from_lines("gitwildmatch", lines)
125
126
127 def normalize_path_maybe_ignore(
128     path: Path, root: Path, report: Report
129 ) -> Optional[str]:
130     """Normalize `path`. May return `None` if `path` was ignored.
131
132     `report` is where "path ignored" output goes.
133     """
134     try:
135         abspath = path if path.is_absolute() else Path.cwd() / path
136         normalized_path = abspath.resolve().relative_to(root).as_posix()
137     except OSError as e:
138         report.path_ignored(path, f"cannot be read because {e}")
139         return None
140
141     except ValueError:
142         if path.is_symlink():
143             report.path_ignored(path, f"is a symbolic link that points outside {root}")
144             return None
145
146         raise
147
148     return normalized_path
149
150
151 def path_is_excluded(
152     normalized_path: str,
153     pattern: Optional[Pattern[str]],
154 ) -> bool:
155     match = pattern.search(normalized_path) if pattern else None
156     return bool(match and match.group(0))
157
158
159 def gen_python_files(
160     paths: Iterable[Path],
161     root: Path,
162     include: Pattern[str],
163     exclude: Pattern[str],
164     extend_exclude: Optional[Pattern[str]],
165     force_exclude: Optional[Pattern[str]],
166     report: Report,
167     gitignore: Optional[PathSpec],
168 ) -> Iterator[Path]:
169     """Generate all files under `path` whose paths are not excluded by the
170     `exclude_regex`, `extend_exclude`, or `force_exclude` regexes,
171     but are included by the `include` regex.
172
173     Symbolic links pointing outside of the `root` directory are ignored.
174
175     `report` is where output about exclusions goes.
176     """
177     assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
178     for child in paths:
179         normalized_path = normalize_path_maybe_ignore(child, root, report)
180         if normalized_path is None:
181             continue
182
183         # First ignore files matching .gitignore, if passed
184         if gitignore is not None and gitignore.match_file(normalized_path):
185             report.path_ignored(child, "matches the .gitignore file content")
186             continue
187
188         # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
189         normalized_path = "/" + normalized_path
190         if child.is_dir():
191             normalized_path += "/"
192
193         if path_is_excluded(normalized_path, exclude):
194             report.path_ignored(child, "matches the --exclude regular expression")
195             continue
196
197         if path_is_excluded(normalized_path, extend_exclude):
198             report.path_ignored(
199                 child, "matches the --extend-exclude regular expression"
200             )
201             continue
202
203         if path_is_excluded(normalized_path, force_exclude):
204             report.path_ignored(child, "matches the --force-exclude regular expression")
205             continue
206
207         if child.is_dir():
208             # If gitignore is None, gitignore usage is disabled, while a Falsey
209             # gitignore is when the directory doesn't have a .gitignore file.
210             yield from gen_python_files(
211                 child.iterdir(),
212                 root,
213                 include,
214                 exclude,
215                 extend_exclude,
216                 force_exclude,
217                 report,
218                 gitignore + get_gitignore(child) if gitignore is not None else None,
219             )
220
221         elif child.is_file():
222             include_match = include.search(normalized_path) if include else True
223             if include_match:
224                 yield child
225
226
227 def wrap_stream_for_windows(
228     f: io.TextIOWrapper,
229 ) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]:
230     """
231     Wrap stream with colorama's wrap_stream so colors are shown on Windows.
232
233     If `colorama` is unavailable, the original stream is returned unmodified.
234     Otherwise, the `wrap_stream()` function determines whether the stream needs
235     to be wrapped for a Windows environment and will accordingly either return
236     an `AnsiToWin32` wrapper or the original stream.
237     """
238     try:
239         from colorama.initialise import wrap_stream
240     except ImportError:
241         return f
242     else:
243         # Set `strip=False` to avoid needing to modify test_express_diff_with_color.
244         return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)