Skip to content

Commit 69ca0a4

Browse files
Stijn de Gooijerichard26
andauthored
Infer target version based on project metadata (#3219)
Co-authored-by: Richard Si <sichard26@gmail.com>
1 parent c4bd2e3 commit 69ca0a4

File tree

11 files changed

+203
-9
lines changed

11 files changed

+203
-9
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ repos:
4848
- tomli >= 0.2.6, < 2.0.0
4949
- types-typed-ast >= 1.4.1
5050
- click >= 8.1.0
51+
- packaging >= 22.0
5152
- platformdirs >= 2.1.0
5253
- pytest
5354
- hypothesis

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@
7777

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

80+
- Black now tries to infer its `--target-version` from the project metadata specified in
81+
`pyproject.toml` (#3219)
82+
8083
### Packaging
8184

8285
<!-- Changes to how Black is packaged, such as dependency requirements -->
@@ -86,6 +89,8 @@
8689
- Drop specific support for the `tomli` requirement on 3.11 alpha releases, working
8790
around a bug that would cause the requirement not to be installed on any non-final
8891
Python releases (#3448)
92+
- Black now depends on `packaging` version `22.0` or later. This is required for new
93+
functionality that needs to parse part of the project metadata (#3219)
8994

9095
### Parser
9196

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ classifiers = [
6565
dependencies = [
6666
"click>=8.0.0",
6767
"mypy_extensions>=0.4.3",
68+
"packaging>=22.0",
6869
"pathspec>=0.9.0",
6970
"platformdirs>=2",
7071
"tomli>=1.1.0; python_version < '3.11'",

src/black/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,9 @@ def validate_regex(
219219
callback=target_version_option_callback,
220220
multiple=True,
221221
help=(
222-
"Python versions that should be supported by Black's output. [default: per-file"
223-
" auto-detection]"
222+
"Python versions that should be supported by Black's output. By default, Black"
223+
" will try to infer this from the project metadata in pyproject.toml. If this"
224+
" does not yield conclusive results, Black will use per-file auto-detection."
224225
),
225226
)
226227
@click.option(

src/black/files.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
)
1919

2020
from mypy_extensions import mypyc_attr
21+
from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet
22+
from packaging.version import InvalidVersion, Version
2123
from pathspec import PathSpec
2224
from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
2325

@@ -32,6 +34,7 @@
3234
import tomli as tomllib
3335

3436
from black.handle_ipynb_magics import jupyter_dependencies_are_installed
37+
from black.mode import TargetVersion
3538
from black.output import err
3639
from black.report import Report
3740

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

109112
@mypyc_attr(patchable=True)
110113
def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
111-
"""Parse a pyproject toml file, pulling out relevant parts for Black
114+
"""Parse a pyproject toml file, pulling out relevant parts for Black.
112115
113-
If parsing fails, will raise a tomllib.TOMLDecodeError
116+
If parsing fails, will raise a tomllib.TOMLDecodeError.
114117
"""
115118
with open(path_config, "rb") as f:
116119
pyproject_toml = tomllib.load(f)
117-
config = pyproject_toml.get("tool", {}).get("black", {})
118-
return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
120+
config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {})
121+
config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
122+
123+
if "target_version" not in config:
124+
inferred_target_version = infer_target_version(pyproject_toml)
125+
if inferred_target_version is not None:
126+
config["target_version"] = [v.name.lower() for v in inferred_target_version]
127+
128+
return config
129+
130+
131+
def infer_target_version(
132+
pyproject_toml: Dict[str, Any]
133+
) -> Optional[List[TargetVersion]]:
134+
"""Infer Black's target version from the project metadata in pyproject.toml.
135+
136+
Supports the PyPA standard format (PEP 621):
137+
https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
138+
139+
If the target version cannot be inferred, returns None.
140+
"""
141+
project_metadata = pyproject_toml.get("project", {})
142+
requires_python = project_metadata.get("requires-python", None)
143+
if requires_python is not None:
144+
try:
145+
return parse_req_python_version(requires_python)
146+
except InvalidVersion:
147+
pass
148+
try:
149+
return parse_req_python_specifier(requires_python)
150+
except (InvalidSpecifier, InvalidVersion):
151+
pass
152+
153+
return None
154+
155+
156+
def parse_req_python_version(requires_python: str) -> Optional[List[TargetVersion]]:
157+
"""Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion.
158+
159+
If parsing fails, will raise a packaging.version.InvalidVersion error.
160+
If the parsed version cannot be mapped to a valid TargetVersion, returns None.
161+
"""
162+
version = Version(requires_python)
163+
if version.release[0] != 3:
164+
return None
165+
try:
166+
return [TargetVersion(version.release[1])]
167+
except (IndexError, ValueError):
168+
return None
169+
170+
171+
def parse_req_python_specifier(requires_python: str) -> Optional[List[TargetVersion]]:
172+
"""Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion.
173+
174+
If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error.
175+
If the parsed specifier cannot be mapped to a valid TargetVersion, returns None.
176+
"""
177+
specifier_set = strip_specifier_set(SpecifierSet(requires_python))
178+
if not specifier_set:
179+
return None
180+
181+
target_version_map = {f"3.{v.value}": v for v in TargetVersion}
182+
compatible_versions: List[str] = list(specifier_set.filter(target_version_map))
183+
if compatible_versions:
184+
return [target_version_map[v] for v in compatible_versions]
185+
return None
186+
187+
188+
def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet:
189+
"""Strip minor versions for some specifiers in the specifier set.
190+
191+
For background on version specifiers, see PEP 440:
192+
https://peps.python.org/pep-0440/#version-specifiers
193+
"""
194+
specifiers = []
195+
for s in specifier_set:
196+
if "*" in str(s):
197+
specifiers.append(s)
198+
elif s.operator in ["~=", "==", ">=", "==="]:
199+
version = Version(s.version)
200+
stripped = Specifier(f"{s.operator}{version.major}.{version.minor}")
201+
specifiers.append(stripped)
202+
elif s.operator == ">":
203+
version = Version(s.version)
204+
if len(version.release) > 2:
205+
s = Specifier(f">={version.major}.{version.minor}")
206+
specifiers.append(s)
207+
else:
208+
specifiers.append(s)
209+
210+
return SpecifierSet(",".join(str(s) for s in specifiers))
119211

120212

121213
@lru_cache()

src/black/parsing.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
else:
1212
from typing import Final
1313

14-
from black.mode import Feature, TargetVersion, supports_feature
14+
from black.mode import VERSION_TO_FEATURES, Feature, TargetVersion, supports_feature
1515
from black.nodes import syms
1616
from blib2to3 import pygram
1717
from blib2to3.pgen2 import driver
@@ -52,7 +52,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]:
5252
if not target_versions:
5353
# No target_version specified, so try all grammars.
5454
return [
55-
# Python 3.7+
55+
# Python 3.7-3.9
5656
pygram.python_grammar_no_print_statement_no_exec_statement_async_keywords,
5757
# Python 3.0-3.6
5858
pygram.python_grammar_no_print_statement_no_exec_statement,
@@ -72,7 +72,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]:
7272
if not supports_feature(target_versions, Feature.ASYNC_KEYWORDS):
7373
# Python 3.0-3.6
7474
grammars.append(pygram.python_grammar_no_print_statement_no_exec_statement)
75-
if supports_feature(target_versions, Feature.PATTERN_MATCHING):
75+
if any(Feature.PATTERN_MATCHING in VERSION_TO_FEATURES[v] for v in target_versions):
7676
# Python 3.10+
7777
grammars.append(pygram.python_grammar_soft_keywords)
7878

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[project]
2+
name = "test"
3+
version = "1.0.0"
4+
requires-python = ">=3.7,<3.11"
5+
6+
[tool.black]
7+
line-length = 79
8+
target-version = ["py310"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "test"
3+
version = "1.0.0"
4+
5+
[tool.black]
6+
line-length = 79
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
name = "test"
3+
version = "1.0.0"
4+
5+
[tool.black]
6+
line-length = 79
7+
target-version = ["py310"]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
name = "test"
3+
version = "1.0.0"
4+
requires-python = ">=3.7,<3.11"
5+
6+
[tool.black]
7+
line-length = 79

0 commit comments

Comments
 (0)