diff --git a/src/python/pants/backend/python/typecheck/mypy/rules.py b/src/python/pants/backend/python/typecheck/mypy/rules.py index 955092819b3..fac3f133b77 100644 --- a/src/python/pants/backend/python/typecheck/mypy/rules.py +++ b/src/python/pants/backend/python/typecheck/mypy/rules.py @@ -2,10 +2,12 @@ # Licensed under the Apache License, Version 2.0 (see LICENSE). import itertools +import logging +from collections import defaultdict from dataclasses import dataclass from pathlib import PurePath from textwrap import dedent -from typing import Tuple +from typing import Optional, Tuple from pants.backend.python.target_types import ( PythonInterpreterCompatibility, @@ -33,6 +35,7 @@ from pants.engine.fs import ( CreateDigest, Digest, + DigestContents, FileContent, GlobMatchErrorBehavior, MergeDigests, @@ -40,12 +43,15 @@ ) from pants.engine.process import FallibleProcessResult from pants.engine.rules import Get, MultiGet, collect_rules, rule -from pants.engine.target import FieldSet, TransitiveTargets +from pants.engine.target import FieldSet, Target, TransitiveTargets from pants.engine.unions import UnionRule from pants.python.python_setup import PythonSetup from pants.util.logging import LogLevel +from pants.util.ordered_set import FrozenOrderedSet, OrderedSet from pants.util.strutil import pluralize +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class MyPyFieldSet(FieldSet): @@ -54,19 +60,65 @@ class MyPyFieldSet(FieldSet): sources: PythonSources +@dataclass(frozen=True) +class MyPyPartition: + field_set_addresses: FrozenOrderedSet[Address] + closure: FrozenOrderedSet[Target] + interpreter_constraints: PexInterpreterConstraints + python_version_already_configured: bool + + class MyPyRequest(TypecheckRequest): field_set_type = MyPyFieldSet -def generate_args(mypy: MyPy, *, file_list_path: str) -> Tuple[str, ...]: +def generate_argv( + mypy: MyPy, *, file_list_path: str, python_version: Optional[str] +) -> Tuple[str, ...]: args = [] if mypy.config: args.append(f"--config-file={mypy.config}") + if python_version: + args.append(f"--python-version={python_version}") args.extend(mypy.args) args.append(f"@{file_list_path}") return tuple(args) +def check_and_warn_if_python_version_configured( + *, config: Optional[FileContent], args: Tuple[str, ...] +) -> bool: + configured = [] + if config and b"python_version" in config.content: + configured.append( + f"`python_version` in {config.path} (which is used because of the " + "`[mypy].config` option)" + ) + if "--py2" in args: + configured.append("`--py2` in the `--mypy-args` option") + if any(arg.startswith("--python-version") for arg in args): + configured.append("`--python-version` in the `--mypy-args` option") + if configured: + formatted_configured = " and you set ".join(configured) + logger.warning( + f"You set {formatted_configured}. Normally, Pants would automatically set this for you " + "based on your code's interpreter constraints " + "(https://www.pantsbuild.org/docs/python-interpreter-compatibility). Instead, it will " + "use what you set.\n\n(Automatically setting the option allows Pants to partition your " + "targets by their constraints, so that, for example, you can run MyPy on Python 2-only " + "code and Python 3-only code at the same time. This feature may no longer work.)" + ) + return bool(configured) + + +def config_path_globs(mypy: MyPy) -> PathGlobs: + return PathGlobs( + globs=[mypy.config] if mypy.config else [], + glob_match_error_behavior=GlobMatchErrorBehavior.error, + description_of_origin="the option `--mypy-config`", + ) + + # MyPy searches for types for a package in packages containing a `py.types` marker file or else in # a sibling `-stubs` package as per PEP-0561. Going further than that PEP, MyPy restricts # its search to `site-packages`. Since PEX deliberately isolates itself from `site-packages` as @@ -113,27 +165,15 @@ def generate_args(mypy: MyPy, *, file_list_path: str) -> Tuple[str, ...]: ) -# TODO(#10131): Improve performance, e.g. by leveraging the MyPy cache. -# TODO(#10131): Support .pyi files. -@rule(desc="Typecheck using MyPy", level=LogLevel.DEBUG) -async def mypy_typecheck( - request: MyPyRequest, mypy: MyPy, python_setup: PythonSetup -) -> TypecheckResults: - if mypy.skip: - return TypecheckResults([], typechecker_name="MyPy") - +@rule +async def mypy_typecheck_partition(partition: MyPyPartition, mypy: MyPy) -> TypecheckResult: plugin_target_addresses = await MultiGet( Get(Address, AddressInput, plugin_addr) for plugin_addr in mypy.source_plugins ) plugin_transitive_targets_request = Get(TransitiveTargets, Addresses(plugin_target_addresses)) - typechecked_transitive_targets_request = Get( - TransitiveTargets, Addresses(fs.address for fs in request.field_sets) - ) - plugin_transitive_targets, typechecked_transitive_targets, launcher_script = await MultiGet( - plugin_transitive_targets_request, - typechecked_transitive_targets_request, - Get(Digest, CreateDigest([LAUNCHER_FILE])), + plugin_transitive_targets, launcher_script = await MultiGet( + plugin_transitive_targets_request, Get(Digest, CreateDigest([LAUNCHER_FILE])) ) plugin_requirements = PexRequirements.create_from_requirement_fields( @@ -142,59 +182,51 @@ async def mypy_typecheck( if plugin_tgt.has_field(PythonRequirementsField) ) - # Interpreter constraints are tricky with MyPy: - # * MyPy requires running with Python 3.5+. If run with Python 3.5-3.7, MyPy can understand - # Python 2.7 and 3.4-3.7 thanks to the typed-ast library, but it can't understand 3.8+ If - # run with Python 3.8, it can understand 2.7 and 3.4-3.8. So, we need to check if the user - # has code that requires Python 3.8+, and if so, use a tighter requirement. - # - # On top of this, MyPy parses the AST using the value from `python_version` from mypy.ini. - # If this is not configured, it defaults to the interpreter being used. This means that - # running MyPy with Py35 would choke on f-strings in Python 3.6, unless the user set - # `python_version`. We don't want to make the user set this up. (If they do, MyPy will use - # `python_version`, rather than defaulting to the executing interpreter). - # - # * When resolving third-party requirements, we should use the actual requirements. Normally, - # we would merge the requirements.pex with mypy.pex via `--pex-path`. However, this will - # cause a runtime error if the interpreter constraints are different between the PEXes and - # they have incompatible wheels. - # - # Instead, we teach MyPy about the requirements by extracting the distributions from - # requirements.pex and setting EXTRACTED_WHEELS, which our custom launcher script then - # looks for. - code_interpreter_constraints = PexInterpreterConstraints.create_from_compatibility_fields( - ( - tgt[PythonInterpreterCompatibility] - for tgt in typechecked_transitive_targets.closure - if tgt.has_field(PythonInterpreterCompatibility) - ), - python_setup, + # If the user did not set `--python-version` already, we set it ourselves based on their code's + # interpreter constraints. This determines what AST is used by MyPy. + python_version = ( + None + if partition.python_version_already_configured + else partition.interpreter_constraints.minimum_python_version() ) - if not mypy.options.is_default("interpreter_constraints"): - tool_interpreter_constraints = mypy.interpreter_constraints - elif code_interpreter_constraints.requires_python38_or_newer(): - tool_interpreter_constraints = ("CPython>=3.8",) - elif code_interpreter_constraints.requires_python37_or_newer(): - tool_interpreter_constraints = ("CPython>=3.7",) - elif code_interpreter_constraints.requires_python36_or_newer(): - tool_interpreter_constraints = ("CPython>=3.6",) - else: - tool_interpreter_constraints = mypy.interpreter_constraints + # MyPy requires 3.5+ to run, but uses the typed-ast library to work with 2.7, 3.4, 3.5, 3.6, + # and 3.7. However, typed-ast does not understand 3.8, so instead we must run MyPy with + # Python 3.8 when relevant. We only do this if <3.8 can't be used, as we don't want a + # loose requirement like `>=3.6` to result in requiring Python 3.8, which would error if + # 3.8 is not installed on the machine. + tool_interpreter_constraints = PexInterpreterConstraints( + ("CPython>=3.8",) + if ( + mypy.options.is_default("interpreter_constraints") + and partition.interpreter_constraints.requires_python38_or_newer() + ) + else mypy.interpreter_constraints + ) plugin_sources_request = Get( PythonSourceFiles, PythonSourceFilesRequest(plugin_transitive_targets.closure) ) typechecked_sources_request = Get( - PythonSourceFiles, PythonSourceFilesRequest(typechecked_transitive_targets.closure) + PythonSourceFiles, PythonSourceFilesRequest(partition.closure) ) + # Normally, this `requirements.pex` would be merged with mypy.pex via `--pex-path`. However, + # this will cause a runtime error if the interpreter constraints are different between the + # PEXes and they have incompatible wheels. + # + # Instead, we teach MyPy about the requirements by extracting the distributions from + # requirements.pex and setting EXTRACTED_WHEELS, which our custom launcher script then + # looks for. + # + # Conventionally, MyPy users might instead set `MYPYPATH` for this. However, doing this + # results in type checking the requirements themselves. requirements_pex_request = Get( Pex, PexFromTargetsRequest, PexFromTargetsRequest.for_requirements( - (field_set.address for field_set in request.field_sets), - hardcoded_interpreter_constraints=code_interpreter_constraints, + (addr for addr in partition.field_set_addresses), + hardcoded_interpreter_constraints=partition.interpreter_constraints, internal_only=True, ), ) @@ -207,19 +239,12 @@ async def mypy_typecheck( requirements=PexRequirements( itertools.chain(mypy.all_requirements, plugin_requirements) ), - interpreter_constraints=PexInterpreterConstraints(tool_interpreter_constraints), + interpreter_constraints=tool_interpreter_constraints, entry_point=PurePath(LAUNCHER_FILE.path).stem, ), ) - config_digest_request = Get( - Digest, - PathGlobs( - globs=[mypy.config] if mypy.config else [], - glob_match_error_behavior=GlobMatchErrorBehavior.error, - description_of_origin="the option `--mypy-config`", - ), - ) + config_digest_request = Get(Digest, PathGlobs, config_path_globs(mypy)) ( plugin_sources, @@ -275,16 +300,81 @@ async def mypy_typecheck( FallibleProcessResult, PexProcess( mypy_pex, - argv=generate_args(mypy, file_list_path=file_list_path), + argv=generate_argv(mypy, file_list_path=file_list_path, python_version=python_version), input_digest=merged_input_files, extra_env=env, description=f"Run MyPy on {pluralize(len(typechecked_srcs_snapshot.files), 'file')}.", level=LogLevel.DEBUG, ), ) - return TypecheckResults( - [TypecheckResult.from_fallible_process_result(result)], typechecker_name="MyPy" + return TypecheckResult.from_fallible_process_result( + result, partition_description=str(sorted(str(c) for c in partition.interpreter_constraints)) + ) + + +# TODO(#10131): Improve performance, e.g. by leveraging the MyPy cache. +# TODO(#10131): Support .pyi files. +@rule(desc="Typecheck using MyPy", level=LogLevel.DEBUG) +async def mypy_typecheck( + request: MyPyRequest, mypy: MyPy, python_setup: PythonSetup +) -> TypecheckResults: + if mypy.skip: + return TypecheckResults([], typechecker_name="MyPy") + + # We batch targets by their interpreter constraints to ensure, for example, that all Python 2 + # targets run together and all Python 3 targets run together. We can only do this by setting + # the `--python-version` option, but we allow the user to set it as a safety valve. We warn if + # they've set the option. + config_content = await Get(DigestContents, PathGlobs, config_path_globs(mypy)) + python_version_configured = check_and_warn_if_python_version_configured( + config=next(iter(config_content), None), args=mypy.args + ) + + # When determining how to batch by interpreter constraints, we must consider the entire + # transitive closure to get the final resulting constraints. + transitive_targets_per_field_set = await MultiGet( + Get(TransitiveTargets, Addresses([field_set.address])) for field_set in request.field_sets + ) + + interpreter_constraints_to_transitive_targets = defaultdict(set) + for transitive_targets in transitive_targets_per_field_set: + interpreter_constraints = ( + PexInterpreterConstraints.create_from_compatibility_fields( + ( + tgt[PythonInterpreterCompatibility] + for tgt in transitive_targets.closure + if tgt.has_field(PythonInterpreterCompatibility) + ), + python_setup, + ) + or PexInterpreterConstraints(mypy.interpreter_constraints) + ) + interpreter_constraints_to_transitive_targets[interpreter_constraints].add( + transitive_targets + ) + + partitions = [] + for interpreter_constraints, all_transitive_targets in sorted( + interpreter_constraints_to_transitive_targets.items() + ): + combined_roots: OrderedSet[Address] = OrderedSet() + combined_closure: OrderedSet[Target] = OrderedSet() + for transitive_targets in all_transitive_targets: + combined_roots.update(tgt.address for tgt in transitive_targets.roots) + combined_closure.update(transitive_targets.closure) + partitions.append( + MyPyPartition( + FrozenOrderedSet(combined_roots), + FrozenOrderedSet(combined_closure), + interpreter_constraints, + python_version_already_configured=python_version_configured, + ) + ) + + partitioned_results = await MultiGet( + Get(TypecheckResult, MyPyPartition, partition) for partition in partitions ) + return TypecheckResults(partitioned_results, typechecker_name="MyPy") def rules(): diff --git a/src/python/pants/backend/python/typecheck/mypy/rules_integration_test.py b/src/python/pants/backend/python/typecheck/mypy/rules_integration_test.py index 19c1470d6d3..8503dd23bc0 100644 --- a/src/python/pants/backend/python/typecheck/mypy/rules_integration_test.py +++ b/src/python/pants/backend/python/typecheck/mypy/rules_integration_test.py @@ -10,7 +10,11 @@ from pants.backend.python.dependency_inference import rules as dependency_inference_rules from pants.backend.python.target_types import PythonLibrary, PythonRequirementLibrary from pants.backend.python.typecheck.mypy.plugin_target_type import MyPySourcePlugin -from pants.backend.python.typecheck.mypy.rules import MyPyFieldSet, MyPyRequest +from pants.backend.python.typecheck.mypy.rules import ( + MyPyFieldSet, + MyPyRequest, + check_and_warn_if_python_version_configured, +) from pants.backend.python.typecheck.mypy.rules import rules as mypy_rules from pants.core.goals.typecheck import TypecheckResult, TypecheckResults from pants.engine.addresses import Address @@ -18,6 +22,7 @@ from pants.engine.rules import QueryRule from pants.engine.target import Target from pants.testutil.python_interpreter_selection import ( + skip_unless_python27_and_python3_present, skip_unless_python27_present, skip_unless_python38_present, ) @@ -419,6 +424,71 @@ def test_works_with_python38(rule_runner: RuleRunner) -> None: assert "Success: no issues found" in result[0].stdout.strip() +@skip_unless_python27_and_python3_present +def test_uses_correct_python_version(rule_runner: RuleRunner) -> None: + """We set `--python-version` automatically for the user, and also batch based on interpreter + constraints. + + This batching must consider transitive dependencies, so we use a more complex setup where the + dependencies are what have specific constraints that influence the batching. + """ + rule_runner.create_file(f"{PACKAGE}/py2/__init__.py") + rule_runner.create_file( + f"{PACKAGE}/py2/lib.py", + dedent( + """\ + def add(x, y): + # type: (int, int) -> int + print "old school" + return x + y + """ + ), + ) + rule_runner.add_to_build_file(f"{PACKAGE}/py2", "python_library(compatibility='==2.7.*')") + + rule_runner.create_file(f"{PACKAGE}/py3/__init__.py") + rule_runner.create_file( + f"{PACKAGE}/py3/lib.py", + dedent( + """\ + def add(x: int, y: int) -> int: + return x + y + """ + ), + ) + rule_runner.add_to_build_file(f"{PACKAGE}/py3", "python_library(compatibility='>=3.6')") + + # Our input files belong to the same target, which is compatible with both Py2 and Py3. + rule_runner.create_file(f"{PACKAGE}/__init__.py") + rule_runner.create_file( + f"{PACKAGE}/uses_py2.py", "from project.py2.lib import add\nassert add(2, 2) == 4\n" + ) + rule_runner.create_file( + f"{PACKAGE}/uses_py3.py", "from project.py3.lib import add\nassert add(2, 2) == 4\n" + ) + rule_runner.add_to_build_file(PACKAGE, "python_library(compatibility=['==2.7.*', '>=3.6'])") + py2_target = rule_runner.get_target( + Address(PACKAGE, relative_file_path="uses_py2.py"), + create_options_bootstrapper(args=GLOBAL_ARGS), + ) + py3_target = rule_runner.get_target( + Address(PACKAGE, relative_file_path="uses_py3.py"), + create_options_bootstrapper(args=GLOBAL_ARGS), + ) + + result = run_mypy(rule_runner, [py2_target, py3_target]) + assert len(result) == 2 + py2_result, py3_result = sorted(result, key=lambda res: res.partition_description) + + assert py2_result.exit_code == 0 + assert py2_result.partition_description == "['CPython==2.7.*', 'CPython==2.7.*,>=3.6']" + assert "Success: no issues found" in py3_result.stdout + + assert py3_result.exit_code == 0 + assert py3_result.partition_description == "['CPython==2.7.*,>=3.6', 'CPython>=3.6']" + assert "Success: no issues found" in py3_result.stdout.strip() + + def test_mypy_shadows_requirements(rule_runner: RuleRunner) -> None: """Test the behavior of a MyPy requirement shadowing a user's requirement. @@ -584,3 +654,32 @@ def run_mypy_with_plugin(tgt: Target) -> TypecheckResult: result = run_mypy_with_plugin(plugin_tgt) assert result.exit_code == 0 assert "Success: no issues found in 7 source files" in result.stdout + + +def test_warn_if_python_version_configured(caplog) -> None: + def assert_is_configured(*, has_config: bool, args: List[str], warning: str) -> None: + config = FileContent("mypy.ini", b"[mypy]\npython_version = 3.6") if has_config else None + is_configured = check_and_warn_if_python_version_configured(config=config, args=tuple(args)) + assert is_configured + assert len(caplog.records) == 1 + assert warning in caplog.text + caplog.clear() + + assert_is_configured(has_config=True, args=[], warning="You set `python_version` in mypy.ini") + assert_is_configured( + has_config=False, args=["--py2"], warning="You set `--py2` in the `--mypy-args` option" + ) + assert_is_configured( + has_config=False, + args=["--python-version=3.6"], + warning="You set `--python-version` in the `--mypy-args` option", + ) + assert_is_configured( + has_config=True, + args=["--py2", "--python-version=3.6"], + warning=( + "You set `python_version` in mypy.ini (which is used because of the `[mypy].config` " + "option) and you set `--py2` in the `--mypy-args` option and you set " + "`--python-version` in the `--mypy-args` option." + ), + ) diff --git a/src/python/pants/backend/python/typecheck/mypy/subsystem.py b/src/python/pants/backend/python/typecheck/mypy/subsystem.py index a7c4247e2b1..7e73e978139 100644 --- a/src/python/pants/backend/python/typecheck/mypy/subsystem.py +++ b/src/python/pants/backend/python/typecheck/mypy/subsystem.py @@ -14,9 +14,11 @@ class MyPy(PythonToolBase): options_scope = "mypy" default_version = "mypy==0.782" default_entry_point = "mypy" - # See `mypy/rules.py`. We only use these default constraints in some situations. + # See `mypy/rules.py`. We only use these default constraints in some situations. Technically, + # MyPy only requires 3.5+, but some popular plugins like `django-stubs` require 3.6+. Because + # 3.5 is EOL, and users can tweak this back, this seems like a more sensible default. register_interpreter_constraints = True - default_interpreter_constraints = ["CPython>=3.5"] + default_interpreter_constraints = ["CPython>=3.6"] @classmethod def register_options(cls, register): diff --git a/src/python/pants/backend/python/util_rules/pex.py b/src/python/pants/backend/python/util_rules/pex.py index 1f5b9c1a8ef..1bac0565f8f 100644 --- a/src/python/pants/backend/python/util_rules/pex.py +++ b/src/python/pants/backend/python/util_rules/pex.py @@ -96,7 +96,7 @@ class PexInterpreterConstraints(FrozenOrderedSet[Requirement]): def __init__(self, constraints: Iterable[Union[str, Requirement]] = ()) -> None: super().__init__( v if isinstance(v, Requirement) else self.parse_constraint(v) - for v in sorted(constraints) + for v in sorted(constraints, key=lambda c: str(c)) ) @staticmethod @@ -201,21 +201,37 @@ def generate_pex_arg_list(self) -> List[str]: args.extend(["--interpreter-constraint", str(constraint)]) return args + def _includes_version(self, major_minor: str, last_patch: int) -> bool: + patch_versions = list(reversed(range(0, last_patch + 1))) + for req in self: + if any( + req.specifier.contains(f"{major_minor}.{p}") for p in patch_versions # type: ignore[attr-defined] + ): + return True + return False + def includes_python2(self) -> bool: """Checks if any of the constraints include Python 2. This will return True even if the code works with Python 3 too, so long as at least one of the constraints works with Python 2. """ - py27_patch_versions = list( - reversed(range(0, 18)) - ) # The last python 2.7 version was 2.7.18. - for req in self: - if any( - req.specifier.contains(f"2.7.{p}") for p in py27_patch_versions # type: ignore[attr-defined] - ): - return True - return False + last_py27_patch_version = 18 + return self._includes_version("2.7", last_patch=last_py27_patch_version) + + def minimum_python_version(self) -> Optional[str]: + """Find the lowest major.minor Python version that will work with these constraints. + + The constraints may also be compatible with later versions; this is the lowest version that + still works. + """ + if self.includes_python2(): + return "2.7" + max_expected_py3_patch_version = 12 # The current max is 9. + for major_minor in ("3.5", "3.6", "3.7", "3.8", "3.9", "3.10"): + if self._includes_version(major_minor, last_patch=max_expected_py3_patch_version): + return major_minor + return None def _requires_python3_version_or_newer( self, *, allowed_versions: Iterable[str], prior_version: str @@ -242,26 +258,6 @@ def _requires_python3_version_or_newer( return False return True - def requires_python36_or_newer(self) -> bool: - """Checks if the constraints are all for Python 3.6+. - - This will return False if Python 3.6 is allowed, but prior versions like 3.5 are also - allowed. - """ - return self._requires_python3_version_or_newer( - allowed_versions=["3.6", "3.7", "3.8", "3.9"], prior_version="3.5" - ) - - def requires_python37_or_newer(self) -> bool: - """Checks if the constraints are all for Python 3.7+. - - This will return False if Python 3.7 is allowed, but prior versions like 3.6 are also - allowed. - """ - return self._requires_python3_version_or_newer( - allowed_versions=["3.7", "3.8", "3.9"], prior_version="3.6" - ) - def requires_python38_or_newer(self) -> bool: """Checks if the constraints are all for Python 3.8+. diff --git a/src/python/pants/backend/python/util_rules/pex_test.py b/src/python/pants/backend/python/util_rules/pex_test.py index 4a48715e407..e073ebba040 100644 --- a/src/python/pants/backend/python/util_rules/pex_test.py +++ b/src/python/pants/backend/python/util_rules/pex_test.py @@ -159,69 +159,24 @@ def test_interpreter_constraints_do_not_include_python2(constraints): @pytest.mark.parametrize( - "constraints", - [ - ["CPython==3.6.*"], - ["CPython==3.6.1"], - ["CPython==3.7.1"], - ["CPython==3.8.2"], - ["CPython==3.9.1"], - ["CPython>=3.6"], - ["CPython>=3.7"], - ["CPython==3.6.*", "CPython==3.7.*"], - ["PyPy>=3.6"], - ], -) -def test_interpreter_constraints_require_python36(constraints) -> None: - assert PexInterpreterConstraints(constraints).requires_python36_or_newer() is True - - -@pytest.mark.parametrize( - "constraints", + "constraints,expected", [ - ["CPython==3.5.*"], - ["CPython==3.5.3"], - ["CPython>=3.5"], - ["CPython==3.5.*", "CPython==3.6.*"], - ["CPython==3.5.3", "CPython==3.6.3"], - ["PyPy>=3.5"], - ], -) -def test_interpreter_constraints_do_not_require_python36(constraints): - assert PexInterpreterConstraints(constraints).requires_python36_or_newer() is False - - -@pytest.mark.parametrize( - "constraints", - [ - ["CPython==3.7.*"], - ["CPython==3.7.1"], - ["CPython==3.8.1"], - ["CPython==3.9.1"], - ["CPython>=3.7"], - ["CPython>=3.8"], - ["CPython==3.7.*", "CPython==3.8.*"], - ["PyPy>=3.7"], - ], -) -def test_interpreter_constraints_require_python37(constraints) -> None: - assert PexInterpreterConstraints(constraints).requires_python37_or_newer() is True - - -@pytest.mark.parametrize( - "constraints", - [ - ["CPython==3.5.*"], - ["CPython==3.6.*"], - ["CPython==3.6.3"], - ["CPython>=3.6"], - ["CPython==3.6.*", "CPython==3.7.*"], - ["CPython==3.6.3", "CPython==3.7.3"], - ["PyPy>=3.6"], + (["CPython>=2.7"], "2.7"), + (["CPython>=3.5"], "3.5"), + (["CPython>=3.6"], "3.6"), + (["CPython>=3.7"], "3.7"), + (["CPython>=3.8"], "3.8"), + (["CPython>=3.9"], "3.9"), + (["CPython>=3.10"], "3.10"), + (["CPython==2.7.10"], "2.7"), + (["CPython==3.5.*", "CPython>=3.6"], "3.5"), + (["CPython==2.6.*"], None), ], ) -def test_interpreter_constraints_do_not_require_python37(constraints): - assert PexInterpreterConstraints(constraints).requires_python37_or_newer() is False +def test_interpreter_constraints_minimum_python_version( + constraints: List[str], expected: str +) -> None: + assert PexInterpreterConstraints(constraints).minimum_python_version() == expected @pytest.mark.parametrize(