]> git.madduck.net Git - etc/vim.git/commitdiff

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

Infer target version based on project metadata (#3219)
authorStijn de Gooijer <stijn@degooijer.io>
Wed, 1 Feb 2023 02:00:17 +0000 (03:00 +0100)
committerGitHub <noreply@github.com>
Wed, 1 Feb 2023 02:00:17 +0000 (18:00 -0800)
Co-authored-by: Richard Si <sichard26@gmail.com>
.pre-commit-config.yaml
CHANGES.md
pyproject.toml
src/black/__init__.py
src/black/files.py
src/black/parsing.py
tests/data/project_metadata/both_pyproject.toml [new file with mode: 0644]
tests/data/project_metadata/neither_pyproject.toml [new file with mode: 0644]
tests/data/project_metadata/only_black_pyproject.toml [new file with mode: 0644]
tests/data/project_metadata/only_metadata_pyproject.toml [new file with mode: 0644]
tests/test_black.py

index 576f6405d6c73bbfba74a1737efbc82d9421e64b..a69fb6452384787c4eb0ee11092a018a4b6e61fe 100644 (file)
@@ -48,6 +48,7 @@ repos:
           - tomli >= 0.2.6, < 2.0.0
           - types-typed-ast >= 1.4.1
           - click >= 8.1.0
           - 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
           - platformdirs >= 2.1.0
           - pytest
           - hypothesis
index ecc8a41f5054755a03230d87a9d74c869a122400..471567509d351a297438c27cd96c9b09648b5cbf 100644 (file)
@@ -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
 
 <!-- Changes to how Black is packaged, such as dependency requirements -->
 ### Packaging
 
 <!-- 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
   around a bug that would cause the requirement not to be installed on any non-final
   Python releases (#3448)
 - 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
 
 
 ### Parser
 
index ab38908ba15bb0721f0bd3b47b6d9f04a7727680..435626ac8f4dbccc6715aec8d3c0b74de4b4e994 100644 (file)
@@ -65,6 +65,7 @@ classifiers = [
 dependencies = [
   "click>=8.0.0",
   "mypy_extensions>=0.4.3",
 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'",
   "pathspec>=0.9.0",
   "platformdirs>=2",
   "tomli>=1.1.0; python_version < '3.11'",
index 42bdfc1a5ddd2fa57850a98ac68d0086afb43816..4ebf28821c39ca339b05f372de2695246f6fcc99 100644 (file)
@@ -219,8 +219,9 @@ def validate_regex(
     callback=target_version_option_callback,
     multiple=True,
     help=(
     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(
     ),
 )
 @click.option(
index ea517f4ece9c2c2bbeaef35d2714ccdf50511992..8c0131126b7b29f418a17ca6651a7b2a0f79cbdb 100644 (file)
@@ -18,6 +18,8 @@ from typing import (
 )
 
 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.patterns.gitwildmatch import GitWildMatchPatternError
 
 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
     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
 
 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]:
 
 @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)
     """
     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()
 
 
 @lru_cache()
index c37c12b868d7f5e75e73e6f036ea7d7d5bae3604..ba474c5e047cabb0776abc139b96b204f3c58e77 100644 (file)
@@ -11,7 +11,7 @@ if sys.version_info < (3, 8):
 else:
     from typing import Final
 
 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
 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 [
     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,
             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 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)
 
         # 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 (file)
index 0000000..cf8f148
--- /dev/null
@@ -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 (file)
index 0000000..67623d2
--- /dev/null
@@ -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 (file)
index 0000000..94058bb
--- /dev/null
@@ -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 (file)
index 0000000..1c8cdbb
--- /dev/null
@@ -0,0 +1,7 @@
+[project]
+name = "test"
+version = "1.0.0"
+requires-python = ">=3.7,<3.11"
+
+[tool.black]
+line-length = 79
index d0e78b7dd926c4894e5bd3a6d57e7d9e8e9f0bb9..e5e1777771548f0ca3c6c6de59f926e83a99ef07 100644 (file)
@@ -1560,6 +1560,72 @@ class BlackTestCase(BlackBaseTestCase):
         self.assertEqual(config["exclude"], r"\.pyi?$")
         self.assertEqual(config["include"], r"\.py?$")
 
         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()
     def test_read_pyproject_toml(self) -> None:
         test_toml_file = THIS_DIR / "test.toml"
         fake_ctx = FakeContext()