)
from mypy_extensions import mypyc_attr
+from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet
+from packaging.version import InvalidVersion, Version
from pathspec import PathSpec
from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
import tomli as tomllib
from black.handle_ipynb_magics import jupyter_dependencies_are_installed
+from black.mode import TargetVersion
from black.output import err
from black.report import Report
@mypyc_attr(patchable=True)
def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
- """Parse a pyproject toml file, pulling out relevant parts for Black
+ """Parse a pyproject toml file, pulling out relevant parts for Black.
- If parsing fails, will raise a tomllib.TOMLDecodeError
+ If parsing fails, will raise a tomllib.TOMLDecodeError.
"""
with open(path_config, "rb") as f:
pyproject_toml = tomllib.load(f)
- config = pyproject_toml.get("tool", {}).get("black", {})
- return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
+ config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {})
+ config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
+
+ if "target_version" not in config:
+ inferred_target_version = infer_target_version(pyproject_toml)
+ if inferred_target_version is not None:
+ config["target_version"] = [v.name.lower() for v in inferred_target_version]
+
+ return config
+
+
+def infer_target_version(
+ pyproject_toml: Dict[str, Any]
+) -> Optional[List[TargetVersion]]:
+ """Infer Black's target version from the project metadata in pyproject.toml.
+
+ Supports the PyPA standard format (PEP 621):
+ https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
+
+ If the target version cannot be inferred, returns None.
+ """
+ project_metadata = pyproject_toml.get("project", {})
+ requires_python = project_metadata.get("requires-python", None)
+ if requires_python is not None:
+ try:
+ return parse_req_python_version(requires_python)
+ except InvalidVersion:
+ pass
+ try:
+ return parse_req_python_specifier(requires_python)
+ except (InvalidSpecifier, InvalidVersion):
+ pass
+
+ return None
+
+
+def parse_req_python_version(requires_python: str) -> Optional[List[TargetVersion]]:
+ """Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion.
+
+ If parsing fails, will raise a packaging.version.InvalidVersion error.
+ If the parsed version cannot be mapped to a valid TargetVersion, returns None.
+ """
+ version = Version(requires_python)
+ if version.release[0] != 3:
+ return None
+ try:
+ return [TargetVersion(version.release[1])]
+ except (IndexError, ValueError):
+ return None
+
+
+def parse_req_python_specifier(requires_python: str) -> Optional[List[TargetVersion]]:
+ """Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion.
+
+ If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error.
+ If the parsed specifier cannot be mapped to a valid TargetVersion, returns None.
+ """
+ specifier_set = strip_specifier_set(SpecifierSet(requires_python))
+ if not specifier_set:
+ return None
+
+ target_version_map = {f"3.{v.value}": v for v in TargetVersion}
+ compatible_versions: List[str] = list(specifier_set.filter(target_version_map))
+ if compatible_versions:
+ return [target_version_map[v] for v in compatible_versions]
+ return None
+
+
+def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet:
+ """Strip minor versions for some specifiers in the specifier set.
+
+ For background on version specifiers, see PEP 440:
+ https://peps.python.org/pep-0440/#version-specifiers
+ """
+ specifiers = []
+ for s in specifier_set:
+ if "*" in str(s):
+ specifiers.append(s)
+ elif s.operator in ["~=", "==", ">=", "==="]:
+ version = Version(s.version)
+ stripped = Specifier(f"{s.operator}{version.major}.{version.minor}")
+ specifiers.append(stripped)
+ elif s.operator == ">":
+ version = Version(s.version)
+ if len(version.release) > 2:
+ s = Specifier(f">={version.major}.{version.minor}")
+ specifiers.append(s)
+ else:
+ specifiers.append(s)
+
+ return SpecifierSet(",".join(str(s) for s in specifiers))
@lru_cache()
else:
from typing import Final
-from black.mode import Feature, TargetVersion, supports_feature
+from black.mode import VERSION_TO_FEATURES, Feature, TargetVersion, supports_feature
from black.nodes import syms
from blib2to3 import pygram
from blib2to3.pgen2 import driver
if not target_versions:
# No target_version specified, so try all grammars.
return [
- # Python 3.7+
+ # Python 3.7-3.9
pygram.python_grammar_no_print_statement_no_exec_statement_async_keywords,
# Python 3.0-3.6
pygram.python_grammar_no_print_statement_no_exec_statement,
if not supports_feature(target_versions, Feature.ASYNC_KEYWORDS):
# Python 3.0-3.6
grammars.append(pygram.python_grammar_no_print_statement_no_exec_statement)
- if supports_feature(target_versions, Feature.PATTERN_MATCHING):
+ if any(Feature.PATTERN_MATCHING in VERSION_TO_FEATURES[v] for v in target_versions):
# Python 3.10+
grammars.append(pygram.python_grammar_soft_keywords)
self.assertEqual(config["exclude"], r"\.pyi?$")
self.assertEqual(config["include"], r"\.py?$")
+ def test_parse_pyproject_toml_project_metadata(self) -> None:
+ for test_toml, expected in [
+ ("only_black_pyproject.toml", ["py310"]),
+ ("only_metadata_pyproject.toml", ["py37", "py38", "py39", "py310"]),
+ ("neither_pyproject.toml", None),
+ ("both_pyproject.toml", ["py310"]),
+ ]:
+ test_toml_file = THIS_DIR / "data" / "project_metadata" / test_toml
+ config = black.parse_pyproject_toml(str(test_toml_file))
+ self.assertEqual(config.get("target_version"), expected)
+
+ def test_infer_target_version(self) -> None:
+ for version, expected in [
+ ("3.6", [TargetVersion.PY36]),
+ ("3.11.0rc1", [TargetVersion.PY311]),
+ (">=3.10", [TargetVersion.PY310, TargetVersion.PY311]),
+ (">=3.10.6", [TargetVersion.PY310, TargetVersion.PY311]),
+ ("<3.6", [TargetVersion.PY33, TargetVersion.PY34, TargetVersion.PY35]),
+ (">3.7,<3.10", [TargetVersion.PY38, TargetVersion.PY39]),
+ (">3.7,!=3.8,!=3.9", [TargetVersion.PY310, TargetVersion.PY311]),
+ (
+ "> 3.9.4, != 3.10.3",
+ [TargetVersion.PY39, TargetVersion.PY310, TargetVersion.PY311],
+ ),
+ (
+ "!=3.3,!=3.4",
+ [
+ TargetVersion.PY35,
+ TargetVersion.PY36,
+ TargetVersion.PY37,
+ TargetVersion.PY38,
+ TargetVersion.PY39,
+ TargetVersion.PY310,
+ TargetVersion.PY311,
+ ],
+ ),
+ (
+ "==3.*",
+ [
+ TargetVersion.PY33,
+ TargetVersion.PY34,
+ TargetVersion.PY35,
+ TargetVersion.PY36,
+ TargetVersion.PY37,
+ TargetVersion.PY38,
+ TargetVersion.PY39,
+ TargetVersion.PY310,
+ TargetVersion.PY311,
+ ],
+ ),
+ ("==3.8.*", [TargetVersion.PY38]),
+ (None, None),
+ ("", None),
+ ("invalid", None),
+ ("==invalid", None),
+ (">3.9,!=invalid", None),
+ ("3", None),
+ ("3.2", None),
+ ("2.7.18", None),
+ ("==2.7", None),
+ (">3.10,<3.11", None),
+ ]:
+ test_toml = {"project": {"requires-python": version}}
+ result = black.files.infer_target_version(test_toml)
+ self.assertEqual(result, expected)
+
def test_read_pyproject_toml(self) -> None:
test_toml_file = THIS_DIR / "test.toml"
fake_ctx = FakeContext()