Repository: pantsbuild/pants

Issue:
Plugin resolution attempts to match itself, but python-setup settings can get in the way
Plugin resolution must resolve plugins for the exact interpreter that it is running under. But because it uses the `python-setup` settings to constrain interpreters, the interpreter that Pants is actually running under may be filtered out. For example: if you've configured `interpreter_search_path = ["PYENV"]`, but Pants is running with a system python, you may not be able to find an exact match via: https://github.com/pantsbuild/pants/blob/a427ea87287e15e628c2a67c79e60edabcc2255b/src/python/pants/init/plugin_resolver.py#L94

Instead, we should likely give PEX the exact python to use via `--pex-python` (similar to what we do for "internal only" PEXes).

----

In the meantime, to work around this issue users would need to both
1. Override the `PYTHON` selected by the `pants` script to be one selectable by their `python-setup` constraints/search_path.
2. Possibly clear `~/.cache/pants/setup`, since `patch` version changes don't invalidate it.


Discussion:
Seen again: will try to fix this in a pick to 2.6.x.
To reproduce this issue in the pantsbuild/pants repo, you can force the pants script to run with a python that is not on the interpreter search path... e.g.:
```
PY=/usr/bin/python3 ./pants --python-setup-interpreter-search-paths='["PYENV"]' --no-process-execution-local-cache -ldebug list 3rdparty/::
```

Resolving patch:
diff --git a/src/python/pants/backend/python/util_rules/pex.py b/src/python/pants/backend/python/util_rules/pex.py
--- a/src/python/pants/backend/python/util_rules/pex.py
+++ b/src/python/pants/backend/python/util_rules/pex.py
@@ -89,6 +89,7 @@ def generate_pex_arg_list(self) -> List[str]:
 class PexRequest(EngineAwareParameter):
     output_filename: str
     internal_only: bool
+    python: PythonExecutable | None
     requirements: PexRequirements
     interpreter_constraints: InterpreterConstraints
     platforms: PexPlatforms
@@ -106,6 +107,7 @@ def __init__(
         *,
         output_filename: str,
         internal_only: bool,
+        python: PythonExecutable | None = None,
         requirements: PexRequirements = PexRequirements(),
         interpreter_constraints=InterpreterConstraints(),
         platforms=PexPlatforms(),
@@ -126,6 +128,8 @@ def __init__(
             to end users, such as with the `binary` goal. Typically, instead, the user never
             directly uses the Pex, e.g. with `lint` and `test`. If True, we will use a Pex setting
             that results in faster build time but compatibility with fewer interpreters at runtime.
+        :param python: A particular PythonExecutable to use, which must match any relevant
+            interpreter_constraints.
         :param requirements: The requirements to install.
         :param interpreter_constraints: Any constraints on which Python versions may be used.
         :param platforms: Which platforms should be supported. Setting this value will cause
@@ -147,6 +151,7 @@ def __init__(
         """
         self.output_filename = output_filename
         self.internal_only = internal_only
+        self.python = python
         self.requirements = requirements
         self.interpreter_constraints = interpreter_constraints
         self.platforms = platforms
@@ -167,6 +172,16 @@ def __post_init__(self):
                 f"Given platform constraints {self.platforms} for internal only pex request: "
                 f"{self}."
             )
+        if self.python and self.platforms:
+            raise ValueError(
+                "Only one of platforms or a specific interpreter may be set. Got "
+                f"both {self.platforms} and {self.python}."
+            )
+        if self.python and self.interpreter_constraints:
+            raise ValueError(
+                "Only one of interpreter_constraints or a specific interpreter may be set. Got "
+                f"both {self.interpreter_constraints} and {self.python}."
+            )
 
     def debug_hint(self) -> str:
         return self.output_filename
@@ -293,15 +308,22 @@ async def build_pex(
         # TODO(#9560): consider validating that these platforms are valid with the interpreter
         #  constraints.
         argv.extend(request.platforms.generate_pex_arg_list())
-    else:
-        argv.extend(request.interpreter_constraints.generate_pex_arg_list())
+    elif request.python:
+        python = request.python
+    elif request.internal_only:
         # NB: If it's an internal_only PEX, we do our own lookup of the interpreter based on the
         # interpreter constraints, and then will run the PEX with that specific interpreter. We
         # will have already validated that there were no platforms.
-        if request.internal_only:
-            python = await Get(
-                PythonExecutable, InterpreterConstraints, request.interpreter_constraints
-            )
+        python = await Get(
+            PythonExecutable, InterpreterConstraints, request.interpreter_constraints
+        )
+    else:
+        # `--interpreter-constraint` options are mutually exclusive with the `--python` option,
+        # so we only specify them if we have not already located a concrete Python.
+        argv.extend(request.interpreter_constraints.generate_pex_arg_list())
+
+    if python:
+        argv.extend(["--python", python.path])
 
     argv.append("--no-emit-warnings")
 
diff --git a/src/python/pants/init/plugin_resolver.py b/src/python/pants/init/plugin_resolver.py
--- a/src/python/pants/init/plugin_resolver.py
+++ b/src/python/pants/init/plugin_resolver.py
@@ -4,6 +4,7 @@
 import logging
 import site
 import sys
+from dataclasses import dataclass
 from typing import Optional, TypeVar, cast
 
 from pkg_resources import WorkingSet
@@ -11,6 +12,7 @@
 
 from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
 from pants.backend.python.util_rules.pex import PexRequest, PexRequirements, VenvPex, VenvPexProcess
+from pants.backend.python.util_rules.pex_environment import PythonExecutable
 from pants.engine.collection import DeduplicatedCollection
 from pants.engine.environment import CompleteEnvironment
 from pants.engine.internals.session import SessionValues
@@ -28,13 +30,20 @@
 S = TypeVar("S", bound=Subsystem)
 
 
+@dataclass(frozen=True)
+class PluginsRequest:
+    # Interpreter constraints to resolve for, or None to resolve for the interpreter that Pants is
+    # running under.
+    interpreter_constraints: Optional[InterpreterConstraints] = None
+
+
 class ResolvedPluginDistributions(DeduplicatedCollection[str]):
     sort_input = True
 
 
 @rule
 async def resolve_plugins(
-    interpreter_constraints: InterpreterConstraints, global_options: GlobalOptions
+    request: PluginsRequest, global_options: GlobalOptions
 ) -> ResolvedPluginDistributions:
     """This rule resolves plugins using a VenvPex, and exposes the absolute paths of their dists.
 
@@ -46,13 +55,23 @@ async def resolve_plugins(
     if not requirements:
         return ResolvedPluginDistributions()
 
+    python: PythonExecutable | None = None
+    if not request.interpreter_constraints:
+        python = cast(
+            PythonExecutable,
+            PythonExecutable.fingerprinted(
+                sys.executable, ".".join(map(str, sys.version_info[:3])).encode("utf8")
+            ),
+        )
+
     plugins_pex = await Get(
         VenvPex,
         PexRequest(
             output_filename="pants_plugins.pex",
             internal_only=True,
+            python=python,
             requirements=requirements,
-            interpreter_constraints=interpreter_constraints,
+            interpreter_constraints=request.interpreter_constraints,
             # The repository's constraints are not relevant here, because this resolve is mixed
             # into the Pants' process' path, and never into user code.
             apply_requirement_constraints=False,
@@ -88,11 +107,7 @@ def __init__(
         interpreter_constraints: Optional[InterpreterConstraints] = None,
     ) -> None:
         self._scheduler = scheduler
-        self._interpreter_constraints = (
-            interpreter_constraints
-            if interpreter_constraints is not None
-            else InterpreterConstraints([f"=={'.'.join(map(str, sys.version_info[:3]))}"])
-        )
+        self._request = PluginsRequest(interpreter_constraints)
 
     def resolve(
         self,
@@ -129,14 +144,12 @@ def _resolve_plugins(
         )
         return cast(
             ResolvedPluginDistributions,
-            session.product_request(ResolvedPluginDistributions, [self._interpreter_constraints])[
-                0
-            ],
+            session.product_request(ResolvedPluginDistributions, [self._request])[0],
         )
 
 
 def rules():
     return [
-        QueryRule(ResolvedPluginDistributions, [InterpreterConstraints]),
+        QueryRule(ResolvedPluginDistributions, [PluginsRequest]),
         *collect_rules(),
     ]


Test patch:
diff --git a/tests/python/pants_test/init/BUILD b/tests/python/pants_test/init/BUILD
--- a/tests/python/pants_test/init/BUILD
+++ b/tests/python/pants_test/init/BUILD
@@ -3,7 +3,7 @@
 
 python_tests(
   name='tests',
-  timeout = 270,
+  timeout = 360,
   dependencies=[
     # Used by `test_options_initializer`.
     '//:build_root',
diff --git a/tests/python/pants_test/init/test_plugin_resolver.py b/tests/python/pants_test/init/test_plugin_resolver.py
--- a/tests/python/pants_test/init/test_plugin_resolver.py
+++ b/tests/python/pants_test/init/test_plugin_resolver.py
@@ -3,6 +3,7 @@
 
 import os
 import shutil
+import sys
 from contextlib import contextmanager
 from pathlib import Path, PurePath
 from textwrap import dedent
@@ -122,10 +123,12 @@ def provide_chroot(existing):
             with temporary_dir() as new_chroot:
                 yield new_chroot, True
 
+    # Default to resolving with whatever we're currently running with.
     interpreter_constraints = (
-        InterpreterConstraints([f"=={interpreter.identity.version_str}"])
-        if interpreter
-        else InterpreterConstraints([">=3.7"])
+        InterpreterConstraints([f"=={interpreter.identity.version_str}"]) if interpreter else None
+    )
+    artifact_interpreter_constraints = interpreter_constraints or InterpreterConstraints(
+        [f"=={'.'.join(map(str, sys.version_info[:3]))}"]
     )
 
     with provide_chroot(chroot) as (root_dir, create_artifacts):
@@ -149,7 +152,7 @@ def provide_chroot(existing):
                     _run_setup_py(
                         rule_runner,
                         plugin,
-                        interpreter_constraints,
+                        artifact_interpreter_constraints,
                         version,
                         setup_py_args,
                         repo_dir,