From: Stijn de Gooijer Date: Wed, 1 Feb 2023 02:00:17 +0000 (+0100) Subject: Infer target version based on project metadata (#3219) X-Git-Url: https://git.madduck.net/etc/vim.git/commitdiff_plain/69ca0a4c7a365c5f5eea519a90980bab72cab764?ds=inline Infer target version based on project metadata (#3219) Co-authored-by: Richard Si --- diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 576f640..a69fb64 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CHANGES.md b/CHANGES.md index ecc8a41..4715675 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -77,6 +77,9 @@ +- Black now tries to infer its `--target-version` from the project metadata specified in + `pyproject.toml` (#3219) + ### Packaging @@ -86,6 +89,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 diff --git a/pyproject.toml b/pyproject.toml index ab38908..435626a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'", diff --git a/src/black/__init__.py b/src/black/__init__.py index 42bdfc1..4ebf288 100644 --- a/src/black/__init__.py +++ b/src/black/__init__.py @@ -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( diff --git a/src/black/files.py b/src/black/files.py index ea517f4..8c01311 100644 --- a/src/black/files.py +++ b/src/black/files.py @@ -18,6 +18,8 @@ from typing import ( ) 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 @@ -32,6 +34,7 @@ else: 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 @@ -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) + 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() diff --git a/src/black/parsing.py b/src/black/parsing.py index c37c12b..ba474c5 100644 --- a/src/black/parsing.py +++ b/src/black/parsing.py @@ -11,7 +11,7 @@ if sys.version_info < (3, 8): 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 @@ -52,7 +52,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]: 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, @@ -72,7 +72,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]: 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) diff --git a/tests/data/project_metadata/both_pyproject.toml b/tests/data/project_metadata/both_pyproject.toml new file mode 100644 index 0000000..cf8f148 --- /dev/null +++ b/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"] diff --git a/tests/data/project_metadata/neither_pyproject.toml b/tests/data/project_metadata/neither_pyproject.toml new file mode 100644 index 0000000..67623d2 --- /dev/null +++ b/tests/data/project_metadata/neither_pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "test" +version = "1.0.0" + +[tool.black] +line-length = 79 diff --git a/tests/data/project_metadata/only_black_pyproject.toml b/tests/data/project_metadata/only_black_pyproject.toml new file mode 100644 index 0000000..94058bb --- /dev/null +++ b/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"] diff --git a/tests/data/project_metadata/only_metadata_pyproject.toml b/tests/data/project_metadata/only_metadata_pyproject.toml new file mode 100644 index 0000000..1c8cdbb --- /dev/null +++ b/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 diff --git a/tests/test_black.py b/tests/test_black.py index d0e78b7..e5e1777 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -1560,6 +1560,72 @@ class BlackTestCase(BlackBaseTestCase): 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()