Infer target version based on project metadata (#3219)
Co-authored-by: Richard Si <sichard26@gmail.com>
This commit is contained in:
parent
c4bd2e31ce
commit
69ca0a4c7a
@ -48,6 +48,7 @@ repos:
|
|||||||
- tomli >= 0.2.6, < 2.0.0
|
- tomli >= 0.2.6, < 2.0.0
|
||||||
- types-typed-ast >= 1.4.1
|
- types-typed-ast >= 1.4.1
|
||||||
- click >= 8.1.0
|
- click >= 8.1.0
|
||||||
|
- packaging >= 22.0
|
||||||
- platformdirs >= 2.1.0
|
- platformdirs >= 2.1.0
|
||||||
- pytest
|
- pytest
|
||||||
- hypothesis
|
- hypothesis
|
||||||
|
@ -77,6 +77,9 @@
|
|||||||
|
|
||||||
<!-- Changes to how Black can be configured -->
|
<!-- Changes to how Black can be configured -->
|
||||||
|
|
||||||
|
- Black now tries to infer its `--target-version` from the project metadata specified in
|
||||||
|
`pyproject.toml` (#3219)
|
||||||
|
|
||||||
### Packaging
|
### Packaging
|
||||||
|
|
||||||
<!-- Changes to how Black is packaged, such as dependency requirements -->
|
<!-- Changes to how Black is packaged, such as dependency requirements -->
|
||||||
@ -86,6 +89,8 @@
|
|||||||
- Drop specific support for the `tomli` requirement on 3.11 alpha releases, working
|
- Drop specific support for the `tomli` requirement on 3.11 alpha releases, working
|
||||||
around a bug that would cause the requirement not to be installed on any non-final
|
around a bug that would cause the requirement not to be installed on any non-final
|
||||||
Python releases (#3448)
|
Python releases (#3448)
|
||||||
|
- Black now depends on `packaging` version `22.0` or later. This is required for new
|
||||||
|
functionality that needs to parse part of the project metadata (#3219)
|
||||||
|
|
||||||
### Parser
|
### Parser
|
||||||
|
|
||||||
|
@ -65,6 +65,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"click>=8.0.0",
|
"click>=8.0.0",
|
||||||
"mypy_extensions>=0.4.3",
|
"mypy_extensions>=0.4.3",
|
||||||
|
"packaging>=22.0",
|
||||||
"pathspec>=0.9.0",
|
"pathspec>=0.9.0",
|
||||||
"platformdirs>=2",
|
"platformdirs>=2",
|
||||||
"tomli>=1.1.0; python_version < '3.11'",
|
"tomli>=1.1.0; python_version < '3.11'",
|
||||||
|
@ -219,8 +219,9 @@ def validate_regex(
|
|||||||
callback=target_version_option_callback,
|
callback=target_version_option_callback,
|
||||||
multiple=True,
|
multiple=True,
|
||||||
help=(
|
help=(
|
||||||
"Python versions that should be supported by Black's output. [default: per-file"
|
"Python versions that should be supported by Black's output. By default, Black"
|
||||||
" auto-detection]"
|
" will try to infer this from the project metadata in pyproject.toml. If this"
|
||||||
|
" does not yield conclusive results, Black will use per-file auto-detection."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
|
@ -18,6 +18,8 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
from mypy_extensions import mypyc_attr
|
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 import PathSpec
|
||||||
from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
|
from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
|
||||||
|
|
||||||
@ -32,6 +34,7 @@
|
|||||||
import tomli as tomllib
|
import tomli as tomllib
|
||||||
|
|
||||||
from black.handle_ipynb_magics import jupyter_dependencies_are_installed
|
from black.handle_ipynb_magics import jupyter_dependencies_are_installed
|
||||||
|
from black.mode import TargetVersion
|
||||||
from black.output import err
|
from black.output import err
|
||||||
from black.report import Report
|
from black.report import Report
|
||||||
|
|
||||||
@ -108,14 +111,103 @@ def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]:
|
|||||||
|
|
||||||
@mypyc_attr(patchable=True)
|
@mypyc_attr(patchable=True)
|
||||||
def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
|
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:
|
with open(path_config, "rb") as f:
|
||||||
pyproject_toml = tomllib.load(f)
|
pyproject_toml = tomllib.load(f)
|
||||||
config = pyproject_toml.get("tool", {}).get("black", {})
|
config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {})
|
||||||
return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
|
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()
|
@lru_cache()
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
else:
|
else:
|
||||||
from typing import Final
|
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 black.nodes import syms
|
||||||
from blib2to3 import pygram
|
from blib2to3 import pygram
|
||||||
from blib2to3.pgen2 import driver
|
from blib2to3.pgen2 import driver
|
||||||
@ -52,7 +52,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]:
|
|||||||
if not target_versions:
|
if not target_versions:
|
||||||
# No target_version specified, so try all grammars.
|
# No target_version specified, so try all grammars.
|
||||||
return [
|
return [
|
||||||
# Python 3.7+
|
# Python 3.7-3.9
|
||||||
pygram.python_grammar_no_print_statement_no_exec_statement_async_keywords,
|
pygram.python_grammar_no_print_statement_no_exec_statement_async_keywords,
|
||||||
# Python 3.0-3.6
|
# Python 3.0-3.6
|
||||||
pygram.python_grammar_no_print_statement_no_exec_statement,
|
pygram.python_grammar_no_print_statement_no_exec_statement,
|
||||||
@ -72,7 +72,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]:
|
|||||||
if not supports_feature(target_versions, Feature.ASYNC_KEYWORDS):
|
if not supports_feature(target_versions, Feature.ASYNC_KEYWORDS):
|
||||||
# Python 3.0-3.6
|
# Python 3.0-3.6
|
||||||
grammars.append(pygram.python_grammar_no_print_statement_no_exec_statement)
|
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+
|
# Python 3.10+
|
||||||
grammars.append(pygram.python_grammar_soft_keywords)
|
grammars.append(pygram.python_grammar_soft_keywords)
|
||||||
|
|
||||||
|
8
tests/data/project_metadata/both_pyproject.toml
Normal file
8
tests/data/project_metadata/both_pyproject.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[project]
|
||||||
|
name = "test"
|
||||||
|
version = "1.0.0"
|
||||||
|
requires-python = ">=3.7,<3.11"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 79
|
||||||
|
target-version = ["py310"]
|
6
tests/data/project_metadata/neither_pyproject.toml
Normal file
6
tests/data/project_metadata/neither_pyproject.toml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[project]
|
||||||
|
name = "test"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 79
|
7
tests/data/project_metadata/only_black_pyproject.toml
Normal file
7
tests/data/project_metadata/only_black_pyproject.toml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[project]
|
||||||
|
name = "test"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 79
|
||||||
|
target-version = ["py310"]
|
7
tests/data/project_metadata/only_metadata_pyproject.toml
Normal file
7
tests/data/project_metadata/only_metadata_pyproject.toml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[project]
|
||||||
|
name = "test"
|
||||||
|
version = "1.0.0"
|
||||||
|
requires-python = ">=3.7,<3.11"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 79
|
@ -1560,6 +1560,72 @@ def test_parse_pyproject_toml(self) -> None:
|
|||||||
self.assertEqual(config["exclude"], r"\.pyi?$")
|
self.assertEqual(config["exclude"], r"\.pyi?$")
|
||||||
self.assertEqual(config["include"], r"\.py?$")
|
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:
|
def test_read_pyproject_toml(self) -> None:
|
||||||
test_toml_file = THIS_DIR / "test.toml"
|
test_toml_file = THIS_DIR / "test.toml"
|
||||||
fake_ctx = FakeContext()
|
fake_ctx = FakeContext()
|
||||||
|
Loading…
Reference in New Issue
Block a user