Repository: pantsbuild/pants

Issue:
`python_distribution` should include `Requires-Python`
**Is your feature request related to a problem? Please describe.**

In order to avoid surprises for consumers of a python dist, any applicable interpreter constraints should be communicated.

**Describe the solution you'd like**

By providing the `Requires-Python` in the distributions metadata, pip and other tools can correctly relate to which versions to consider for any given environment.

**Describe alternatives you've considered**

Manually providing this field in the BUILD file, is error prone, repetitive and easily overlooked. (The `pantsbuild.pants` distribution doesn't even provide this metadata)
 
**Additional context**

This is an easy fix to avoid hard to diagnose problems, for those who are not python packaging experts.

https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#python-requires



Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/python/goals/setup_py.py b/src/python/pants/backend/python/goals/setup_py.py
--- a/src/python/pants/backend/python/goals/setup_py.py
+++ b/src/python/pants/backend/python/goals/setup_py.py
@@ -86,6 +86,7 @@
 from pants.util.memo import memoized_property
 from pants.util.meta import frozen_after_init
 from pants.util.ordered_set import FrozenOrderedSet
+from pants.util.strutil import softwrap
 
 logger = logging.getLogger(__name__)
 
@@ -198,7 +199,7 @@ class DistBuildChrootRequest:
     """A request to create a chroot for building a dist in."""
 
     exported_target: ExportedTarget
-    py2: bool  # Whether to use py2 or py3 package semantics.
+    interpreter_constraints: InterpreterConstraints
 
 
 @frozen_after_init
@@ -224,11 +225,11 @@ def __init__(
         if not _allow_banned_keys:
             for arg in {
                 "data_files",
+                "install_requires",
                 "namespace_packages",
-                "package_dir",
                 "package_data",
+                "package_dir",
                 "packages",
-                "install_requires",
             }:
                 if arg in kwargs:
                     raise ValueError(
@@ -392,7 +393,7 @@ async def package_python_dist(
         DistBuildChroot,
         DistBuildChrootRequest(
             exported_target,
-            py2=interpreter_constraints.includes_python2(),
+            interpreter_constraints=interpreter_constraints,
         ),
     )
 
@@ -465,6 +466,7 @@ async def determine_explicitly_provided_setup_kwargs(
 class GenerateSetupPyRequest:
     exported_target: ExportedTarget
     sources: DistBuildSources
+    interpreter_constraints: InterpreterConstraints
 
 
 @dataclass(frozen=True)
@@ -484,7 +486,10 @@ async def generate_chroot(
 
     if generate_setup:
         generated_setup_py = await Get(
-            GeneratedSetupPy, GenerateSetupPyRequest(request.exported_target, sources)
+            GeneratedSetupPy,
+            GenerateSetupPyRequest(
+                request.exported_target, sources, request.interpreter_constraints
+            ),
         )
         # We currently generate a setup.py that expects to be in the source root.
         # TODO: It might make sense to generate one in the target's directory, for
@@ -536,6 +541,38 @@ async def determine_finalized_setup_kwargs(request: GenerateSetupPyRequest) -> F
     resolved_setup_kwargs = await Get(SetupKwargs, ExportedTarget, exported_target)
     setup_kwargs = resolved_setup_kwargs.kwargs.copy()
 
+    # Check interpreter constraints
+    if len(request.interpreter_constraints) > 1:
+        raise SetupPyError(
+            softwrap(
+                f"""
+                Expected a single interpreter constraint for {target.address}, got:
+                {request.interpreter_constraints}.
+
+                Python distributions do not support multiple constraints, so this will need to be
+                translated into a single interpreter constraint using exclusions to get the same
+                effect.
+
+                As example, given two constraints:
+
+                    >=2.7,<3 OR >=3.5,<3.11
+
+                these can be combined into a single constraint using exclusions:
+
+                    >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<3.11
+
+                """
+            )
+        )
+    if len(request.interpreter_constraints) > 0:
+        # Do not replace value if already set.
+        setup_kwargs.setdefault(
+            "python_requires",
+            # Pick the first constraint using a generator detour, as the InterpreterConstraints is
+            # based on a FrozenOrderedSet which is not indexable.
+            next(str(ic.specifier) for ic in request.interpreter_constraints),  # type: ignore[attr-defined]
+        )
+
     # NB: We are careful to not overwrite these values, but we also don't expect them to have been
     # set. The user must have have gone out of their way to use a `SetupKwargs` plugin, and to have
     # specified `SetupKwargs(_allow_banned_keys=True)`.
@@ -673,7 +710,8 @@ async def get_sources(
         python_files=python_files,
         resource_files=resource_files,
         init_py_digest_contents=init_py_digest_contents,
-        py2=request.py2,
+        # Whether to use py2 or py3 package semantics.
+        py2=request.interpreter_constraints.includes_python2(),
     )
     return DistBuildSources(
         digest=all_sources.stripped_source_files.snapshot.digest,


Test patch:
diff --git a/src/python/pants/backend/python/goals/setup_py_test.py b/src/python/pants/backend/python/goals/setup_py_test.py
--- a/src/python/pants/backend/python/goals/setup_py_test.py
+++ b/src/python/pants/backend/python/goals/setup_py_test.py
@@ -28,6 +28,7 @@
     OwnedDependency,
     SetupKwargs,
     SetupKwargsRequest,
+    SetupPyError,
     SetupPyGeneration,
     declares_pkg_resources_namespace_package,
     determine_explicitly_provided_setup_kwargs,
@@ -43,6 +44,7 @@
     validate_commands,
 )
 from pants.backend.python.macros.python_artifact import PythonArtifact
+from pants.backend.python.subsystems.setup import PythonSetup
 from pants.backend.python.subsystems.setuptools import PythonDistributionFieldSet
 from pants.backend.python.target_types import (
     PexBinary,
@@ -52,6 +54,7 @@
     PythonSourcesGeneratorTarget,
 )
 from pants.backend.python.util_rules import dists, python_sources
+from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
 from pants.core.goals.package import BuiltPackage
 from pants.core.target_types import FileTarget, ResourcesGeneratorTarget, ResourceTarget
 from pants.core.target_types import rules as core_target_types_rules
@@ -61,7 +64,7 @@
 from pants.engine.rules import SubsystemRule, rule
 from pants.engine.target import InvalidFieldException
 from pants.engine.unions import UnionRule
-from pants.testutil.rule_runner import QueryRule, RuleRunner
+from pants.testutil.rule_runner import QueryRule, RuleRunner, engine_error
 
 _namespace_decl = "__import__('pkg_resources').declare_namespace(__name__)"
 
@@ -127,9 +130,17 @@ def assert_chroot(
     expected_files: list[str],
     expected_setup_kwargs,
     addr: Address,
+    interpreter_constraints: InterpreterConstraints | None = None,
 ) -> None:
+    if interpreter_constraints is None:
+        interpreter_constraints = InterpreterConstraints(
+            PythonSetup.default_interpreter_constraints
+        )
+
     tgt = rule_runner.get_target(addr)
-    req = DistBuildChrootRequest(ExportedTarget(tgt), py2=False)
+    req = DistBuildChrootRequest(
+        ExportedTarget(tgt), interpreter_constraints=interpreter_constraints
+    )
     chroot = rule_runner.request(DistBuildChroot, [req])
     snapshot = rule_runner.request(Snapshot, [chroot.digest])
     assert sorted(expected_files) == sorted(snapshot.files)
@@ -137,7 +148,8 @@ def assert_chroot(
     if expected_setup_kwargs is not None:
         sources = rule_runner.request(DistBuildSources, [req])
         setup_kwargs = rule_runner.request(
-            FinalizedSetupKwargs, [GenerateSetupPyRequest(ExportedTarget(tgt), sources)]
+            FinalizedSetupKwargs,
+            [GenerateSetupPyRequest(ExportedTarget(tgt), sources, interpreter_constraints)],
         )
         assert expected_setup_kwargs == setup_kwargs.kwargs
 
@@ -147,7 +159,12 @@ def assert_chroot_error(rule_runner: RuleRunner, addr: Address, exc_cls: type[Ex
     with pytest.raises(ExecutionError) as excinfo:
         rule_runner.request(
             DistBuildChroot,
-            [DistBuildChrootRequest(ExportedTarget(tgt), py2=False)],
+            [
+                DistBuildChrootRequest(
+                    ExportedTarget(tgt),
+                    InterpreterConstraints(PythonSetup.default_interpreter_constraints),
+                )
+            ],
         )
     ex = excinfo.value
     assert len(ex.wrapped_exceptions) == 1
@@ -283,6 +300,7 @@ def test_use_generate_setup_script_package_provenance_agnostic(chroot_rule_runne
                 )
             },
             "install_requires": (),
+            "python_requires": "<4,>=3.7",
         },
         Address("src/python", target_name="foo-dist"),
     )
@@ -410,6 +428,7 @@ def test_generate_chroot(chroot_rule_runner: RuleRunner) -> None:
             "namespace_packages": ("foo",),
             "package_data": {"foo": ("resources/js/code.js",), "foo.qux": ("qux.pyi",)},
             "install_requires": ("baz==1.1.1",),
+            "python_requires": "<4,>=3.7",
             "entry_points": {"console_scripts": ["foo_main = foo.qux.bin:main"]},
         },
         Address("src/python/foo", target_name="foo-dist"),
@@ -481,6 +500,7 @@ def test_generate_chroot_entry_points(chroot_rule_runner: RuleRunner) -> None:
             "namespace_packages": tuple(),
             "package_data": {},
             "install_requires": tuple(),
+            "python_requires": "<4,>=3.7",
             "entry_points": {
                 "console_scripts": [
                     "foo_main = foo.qux.bin:main",
@@ -533,6 +553,7 @@ def test_generate_long_description_field_from_file(chroot_rule_runner: RuleRunne
             "namespace_packages": tuple(),
             "package_data": {},
             "install_requires": tuple(),
+            "python_requires": "<4,>=3.7",
             "long_description": "Some long description.",
         },
         Address("src/python/foo", target_name="foo-dist"),
@@ -692,6 +713,7 @@ def test_binary_shorthand(chroot_rule_runner: RuleRunner) -> None:
             "packages": ("project",),
             "namespace_packages": (),
             "install_requires": (),
+            "python_requires": "<4,>=3.7",
             "package_data": {},
             "entry_points": {"console_scripts": ["foo = project.app:func"]},
         },
@@ -751,7 +773,12 @@ def assert_sources(
         owner_tgt = rule_runner.get_target(Address("src/python/foo", target_name="dist"))
         srcs = rule_runner.request(
             DistBuildSources,
-            [DistBuildChrootRequest(ExportedTarget(owner_tgt), py2=False)],
+            [
+                DistBuildChrootRequest(
+                    ExportedTarget(owner_tgt),
+                    InterpreterConstraints(PythonSetup.default_interpreter_constraints),
+                )
+            ],
         )
         chroot_snapshot = rule_runner.request(Snapshot, [srcs.digest])
 
@@ -1372,3 +1399,39 @@ def test_no_dist_type_selected() -> None:
         "In order to package src/python/aaa:aaa at least one of 'wheel' or 'sdist' must be `True`."
         == str(wrapped_exception)
     )
+
+
+def test_too_many_interpreter_constraints(chroot_rule_runner: RuleRunner) -> None:
+    chroot_rule_runner.write_files(
+        {
+            "src/python/foo/BUILD": textwrap.dedent(
+                """
+                python_distribution(
+                    name='foo-dist',
+                    provides=setup_py(
+                        name='foo',
+                        version='1.2.3',
+                    )
+                )
+                """
+            ),
+        }
+    )
+
+    addr = Address("src/python/foo", target_name="foo-dist")
+    tgt = chroot_rule_runner.get_target(addr)
+    err = (
+        "Expected a single interpreter constraint for src/python/foo:foo-dist, "
+        "got: CPython<3,>=2.7 OR CPython<3.10,>=3.8."
+    )
+
+    with engine_error(SetupPyError, contains=err):
+        chroot_rule_runner.request(
+            DistBuildChroot,
+            [
+                DistBuildChrootRequest(
+                    ExportedTarget(tgt),
+                    InterpreterConstraints([">=2.7,<3", ">=3.8,<3.10"]),
+                )
+            ],
+        )