Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Infer target version based on project metadata #3219

Merged
merged 19 commits into from Feb 1, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Expand Up @@ -48,6 +48,7 @@ repos:
- tomli >= 0.2.6, < 2.0.0
- types-typed-ast >= 1.4.1
- click >= 8.1.0
- packaging >= 22.0
- platformdirs >= 2.1.0
- pytest
- hypothesis
Expand Down
5 changes: 5 additions & 0 deletions CHANGES.md
Expand Up @@ -42,6 +42,9 @@

<!-- Changes to how Black can be configured -->

- Black now tries to infer its `--target-version` from the project metadata specified in
stinodego marked this conversation as resolved.
Show resolved Hide resolved
`pyproject.toml` (#3219)

### Packaging

<!-- Changes to how Black is packaged, such as dependency requirements -->
Expand All @@ -51,6 +54,8 @@
- 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
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

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Expand Up @@ -65,6 +65,7 @@ classifiers = [
dependencies = [
"click>=8.0.0",
"mypy_extensions>=0.4.3",
"packaging>=22.0",
"pathspec>=0.9.0",
"platformdirs>=2",
"tomli>=1.1.0; python_version < '3.11'",
Expand Down
7 changes: 5 additions & 2 deletions src/black/__init__.py
Expand Up @@ -219,8 +219,9 @@ def validate_regex(
callback=target_version_option_callback,
multiple=True,
help=(
"Python versions that should be supported by Black's output. [default: per-file"
" auto-detection]"
"Python versions that should be supported by Black's output. By default, Black"
" 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(
Expand Down Expand Up @@ -519,6 +520,8 @@ def main( # noqa: C901
for param, value in ctx.default_map.items():
out(f"{param}: {value}")

out(f"Target version: {[v.name.lower() for v in target_version]}", fg="blue")

error_msg = "Oh no! 💥 💔 💥"
if (
required_version
Expand Down
100 changes: 96 additions & 4 deletions src/black/files.py
Expand Up @@ -18,6 +18,8 @@
)

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

Expand All @@ -32,6 +34,7 @@
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

Expand Down Expand Up @@ -108,14 +111,103 @@ def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]:

@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)
stinodego marked this conversation as resolved.
Show resolved Hide resolved
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))
stinodego marked this conversation as resolved.
Show resolved Hide resolved
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()
Expand Down
8 changes: 8 additions & 0 deletions tests/data/project_metadata/both_pyproject.toml
@@ -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 changes: 6 additions & 0 deletions tests/data/project_metadata/neither_pyproject.toml
@@ -0,0 +1,6 @@
[project]
name = "test"
version = "1.0.0"

[tool.black]
line-length = 79
7 changes: 7 additions & 0 deletions tests/data/project_metadata/only_black_pyproject.toml
@@ -0,0 +1,7 @@
[project]
name = "test"
version = "1.0.0"

[tool.black]
line-length = 79
target-version = ["py310"]
7 changes: 7 additions & 0 deletions tests/data/project_metadata/only_metadata_pyproject.toml
@@ -0,0 +1,7 @@
[project]
name = "test"
version = "1.0.0"
requires-python = ">=3.7,<3.11"

[tool.black]
line-length = 79
66 changes: 66 additions & 0 deletions tests/test_black.py
Expand Up @@ -1559,6 +1559,72 @@ def test_parse_pyproject_toml(self) -> None:
self.assertEqual(config["exclude"], r"\.pyi?$")
self.assertEqual(config["include"], r"\.py?$")

def test_parse_pyproject_toml_project_metadata(self) -> None:
felix-hilden marked this conversation as resolved.
Show resolved Hide resolved
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:
felix-hilden marked this conversation as resolved.
Show resolved Hide resolved
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()
Expand Down