4 from functools
import lru_cache
5 from pathlib
import Path
20 from mypy_extensions
import mypyc_attr
21 from packaging
.specifiers
import InvalidSpecifier
, Specifier
, SpecifierSet
22 from packaging
.version
import InvalidVersion
, Version
23 from pathspec
import PathSpec
24 from pathspec
.patterns
.gitwildmatch
import GitWildMatchPatternError
26 if sys
.version_info
>= (3, 11):
30 # Help users on older alphas
32 import tomli
as tomllib
34 import tomli
as tomllib
36 from black
.handle_ipynb_magics
import jupyter_dependencies_are_installed
37 from black
.mode
import TargetVersion
38 from black
.output
import err
39 from black
.report
import Report
42 import colorama
# noqa: F401
46 def find_project_root(
47 srcs
: Sequence
[str], stdin_filename
: Optional
[str] = None
48 ) -> Tuple
[Path
, str]:
49 """Return a directory containing .git, .hg, or pyproject.toml.
51 That directory will be a common parent of all files and directories
54 If no directory in the tree contains a marker that would specify it's the
55 project root, the root of the file system is returned.
57 Returns a two-tuple with the first element as the project root path and
58 the second element as a string describing the method by which the
59 project root was discovered.
61 if stdin_filename
is not None:
62 srcs
= tuple(stdin_filename
if s
== "-" else s
for s
in srcs
)
64 srcs
= [str(Path
.cwd().resolve())]
66 path_srcs
= [Path(Path
.cwd(), src
).resolve() for src
in srcs
]
68 # A list of lists of parents for each 'src'. 'src' is included as a
69 # "parent" of itself if it is a directory
71 list(path
.parents
) + ([path
] if path
.is_dir() else []) for path
in path_srcs
75 set.intersection(*(set(parents
) for parents
in src_parents
)),
76 key
=lambda path
: path
.parts
,
79 for directory
in (common_base
, *common_base
.parents
):
80 if (directory
/ ".git").exists():
81 return directory
, ".git directory"
83 if (directory
/ ".hg").is_dir():
84 return directory
, ".hg directory"
86 if (directory
/ "pyproject.toml").is_file():
87 return directory
, "pyproject.toml"
89 return directory
, "file system root"
92 def find_pyproject_toml(
93 path_search_start
: Tuple
[str, ...], stdin_filename
: Optional
[str] = None
95 """Find the absolute filepath to a pyproject.toml if it exists"""
96 path_project_root
, _
= find_project_root(path_search_start
, stdin_filename
)
97 path_pyproject_toml
= path_project_root
/ "pyproject.toml"
98 if path_pyproject_toml
.is_file():
99 return str(path_pyproject_toml
)
102 path_user_pyproject_toml
= find_user_pyproject_toml()
104 str(path_user_pyproject_toml
)
105 if path_user_pyproject_toml
.is_file()
108 except (PermissionError
, RuntimeError) as e
:
109 # We do not have access to the user-level config directory, so ignore it.
110 err(f
"Ignoring user configuration directory due to {e!r}")
114 @mypyc_attr(patchable
=True)
115 def parse_pyproject_toml(path_config
: str) -> Dict
[str, Any
]:
116 """Parse a pyproject toml file, pulling out relevant parts for Black.
118 If parsing fails, will raise a tomllib.TOMLDecodeError.
120 with
open(path_config
, "rb") as f
:
121 pyproject_toml
= tomllib
.load(f
)
122 config
: Dict
[str, Any
] = pyproject_toml
.get("tool", {}).get("black", {})
123 config
= {k
.replace("--", "").replace("-", "_"): v
for k
, v
in config
.items()}
125 if "target_version" not in config
:
126 inferred_target_version
= infer_target_version(pyproject_toml
)
127 if inferred_target_version
is not None:
128 config
["target_version"] = [v
.name
.lower() for v
in inferred_target_version
]
133 def infer_target_version(
134 pyproject_toml
: Dict
[str, Any
]
135 ) -> Optional
[List
[TargetVersion
]]:
136 """Infer Black's target version from the project metadata in pyproject.toml.
138 Supports the PyPA standard format (PEP 621):
139 https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
141 If the target version cannot be inferred, returns None.
143 project_metadata
= pyproject_toml
.get("project", {})
144 requires_python
= project_metadata
.get("requires-python", None)
145 if requires_python
is not None:
147 return parse_req_python_version(requires_python
)
148 except InvalidVersion
:
151 return parse_req_python_specifier(requires_python
)
152 except (InvalidSpecifier
, InvalidVersion
):
158 def parse_req_python_version(requires_python
: str) -> Optional
[List
[TargetVersion
]]:
159 """Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion.
161 If parsing fails, will raise a packaging.version.InvalidVersion error.
162 If the parsed version cannot be mapped to a valid TargetVersion, returns None.
164 version
= Version(requires_python
)
165 if version
.release
[0] != 3:
168 return [TargetVersion(version
.release
[1])]
169 except (IndexError, ValueError):
173 def parse_req_python_specifier(requires_python
: str) -> Optional
[List
[TargetVersion
]]:
174 """Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion.
176 If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error.
177 If the parsed specifier cannot be mapped to a valid TargetVersion, returns None.
179 specifier_set
= strip_specifier_set(SpecifierSet(requires_python
))
180 if not specifier_set
:
183 target_version_map
= {f
"3.{v.value}": v
for v
in TargetVersion
}
184 compatible_versions
: List
[str] = list(specifier_set
.filter(target_version_map
))
185 if compatible_versions
:
186 return [target_version_map
[v
] for v
in compatible_versions
]
190 def strip_specifier_set(specifier_set
: SpecifierSet
) -> SpecifierSet
:
191 """Strip minor versions for some specifiers in the specifier set.
193 For background on version specifiers, see PEP 440:
194 https://peps.python.org/pep-0440/#version-specifiers
197 for s
in specifier_set
:
200 elif s
.operator
in ["~=", "==", ">=", "==="]:
201 version
= Version(s
.version
)
202 stripped
= Specifier(f
"{s.operator}{version.major}.{version.minor}")
203 specifiers
.append(stripped
)
204 elif s
.operator
== ">":
205 version
= Version(s
.version
)
206 if len(version
.release
) > 2:
207 s
= Specifier(f
">={version.major}.{version.minor}")
212 return SpecifierSet(",".join(str(s
) for s
in specifiers
))
216 def find_user_pyproject_toml() -> Path
:
217 r
"""Return the path to the top-level user configuration for black.
219 This looks for ~\.black on Windows and ~/.config/black on Linux and other
223 - RuntimeError: if the current user has no homedir
224 - PermissionError: if the current process cannot access the user's homedir
226 if sys
.platform
== "win32":
228 user_config_path
= Path
.home() / ".black"
230 config_root
= os
.environ
.get("XDG_CONFIG_HOME", "~/.config")
231 user_config_path
= Path(config_root
).expanduser() / "black"
232 return user_config_path
.resolve()
236 def get_gitignore(root
: Path
) -> PathSpec
:
237 """Return a PathSpec matching gitignore content if present."""
238 gitignore
= root
/ ".gitignore"
239 lines
: List
[str] = []
240 if gitignore
.is_file():
241 with gitignore
.open(encoding
="utf-8") as gf
:
242 lines
= gf
.readlines()
244 return PathSpec
.from_lines("gitwildmatch", lines
)
245 except GitWildMatchPatternError
as e
:
246 err(f
"Could not parse {gitignore}: {e}")
250 def normalize_path_maybe_ignore(
253 report
: Optional
[Report
] = None,
255 """Normalize `path`. May return `None` if `path` was ignored.
257 `report` is where "path ignored" output goes.
260 abspath
= path
if path
.is_absolute() else Path
.cwd() / path
261 normalized_path
= abspath
.resolve()
263 root_relative_path
= normalized_path
.relative_to(root
).as_posix()
267 path
, f
"is a symbolic link that points outside {root}"
273 report
.path_ignored(path
, f
"cannot be read because {e}")
276 return root_relative_path
279 def _path_is_ignored(
280 root_relative_path
: str,
282 gitignore_dict
: Dict
[Path
, PathSpec
],
285 path
= root
/ root_relative_path
286 # Note that this logic is sensitive to the ordering of gitignore_dict. Callers must
287 # ensure that gitignore_dict is ordered from least specific to most specific.
288 for gitignore_path
, pattern
in gitignore_dict
.items():
290 relative_path
= path
.relative_to(gitignore_path
).as_posix()
293 if pattern
.match_file(relative_path
):
295 path
.relative_to(root
), "matches a .gitignore file content"
301 def path_is_excluded(
302 normalized_path
: str,
303 pattern
: Optional
[Pattern
[str]],
305 match
= pattern
.search(normalized_path
) if pattern
else None
306 return bool(match
and match
.group(0))
309 def gen_python_files(
310 paths
: Iterable
[Path
],
312 include
: Pattern
[str],
313 exclude
: Pattern
[str],
314 extend_exclude
: Optional
[Pattern
[str]],
315 force_exclude
: Optional
[Pattern
[str]],
317 gitignore_dict
: Optional
[Dict
[Path
, PathSpec
]],
322 """Generate all files under `path` whose paths are not excluded by the
323 `exclude_regex`, `extend_exclude`, or `force_exclude` regexes,
324 but are included by the `include` regex.
326 Symbolic links pointing outside of the `root` directory are ignored.
328 `report` is where output about exclusions goes.
331 assert root
.is_absolute(), f
"INTERNAL ERROR: `root` must be absolute but is {root}"
333 root_relative_path
= child
.absolute().relative_to(root
).as_posix()
335 # First ignore files matching .gitignore, if passed
336 if gitignore_dict
and _path_is_ignored(
337 root_relative_path
, root
, gitignore_dict
, report
341 # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
342 root_relative_path
= "/" + root_relative_path
344 root_relative_path
+= "/"
346 if path_is_excluded(root_relative_path
, exclude
):
347 report
.path_ignored(child
, "matches the --exclude regular expression")
350 if path_is_excluded(root_relative_path
, extend_exclude
):
352 child
, "matches the --extend-exclude regular expression"
356 if path_is_excluded(root_relative_path
, force_exclude
):
357 report
.path_ignored(child
, "matches the --force-exclude regular expression")
360 normalized_path
= normalize_path_maybe_ignore(child
, root
, report
)
361 if normalized_path
is None:
365 # If gitignore is None, gitignore usage is disabled, while a Falsey
366 # gitignore is when the directory doesn't have a .gitignore file.
367 if gitignore_dict
is not None:
368 new_gitignore_dict
= {
370 root
/ child
: get_gitignore(child
),
373 new_gitignore_dict
= None
374 yield from gen_python_files(
387 elif child
.is_file():
388 if child
.suffix
== ".ipynb" and not jupyter_dependencies_are_installed(
389 warn
=verbose
or not quiet
392 include_match
= include
.search(normalized_path
) if include
else True
397 def wrap_stream_for_windows(
399 ) -> Union
[io
.TextIOWrapper
, "colorama.AnsiToWin32"]:
401 Wrap stream with colorama's wrap_stream so colors are shown on Windows.
403 If `colorama` is unavailable, the original stream is returned unmodified.
404 Otherwise, the `wrap_stream()` function determines whether the stream needs
405 to be wrapped for a Windows environment and will accordingly either return
406 an `AnsiToWin32` wrapper or the original stream.
409 from colorama
.initialise
import wrap_stream
413 # Set `strip=False` to avoid needing to modify test_express_diff_with_color.
414 return wrap_stream(f
, convert
=None, strip
=False, autoreset
=False, wrap
=True)