Repository: pantsbuild/pants

Issue:
/usr/bin/env: <python-version> No such file or directory when using adhoc_tool runnable is pex_binary
**Describe the bug**

```
python_source(
    name='lib-hello',
    source='hello.py')

pex_binary(
    name="hi",
    entry_point="hello.py",
)

adhoc_tool(
    name="local-data",
    runnable=":hi",
    args=["--foo"],
    output_directories=["data"],
    log_output=True,
)

python_source(
    name='lib-world',
    source='world.py',
    dependencies=[':local-data']
)
```

Results in
```
$ pants --keep-sandboxes=on_failure run hello_pex/world.py 
17:07:27.22 [INFO] Preserving local process execution dir /tmp/pants-sandbox-LcNOLX for Running the `adhoc_tool` at hello_pex:local-data
17:07:27.22 [INFO] Completed: Running the `adhoc_tool` at hello_pex:local-data
17:07:27.22 [ERROR] 1 Exception encountered:

Engine traceback:
  in `run` goal

ProcessExecutionFailure: Process 'the `adhoc_tool` at hello_pex:local-data' failed with exit code 127.
stdout:

stderr:
/usr/bin/env: 'python3.9': No such file or directory
```

Confusingly, the `__run.sh` from the sandbox runs without error when invoked directly. so I'm not sure what is going on here.

**Pants version**
2.16.0a0, 2.16.0rc0

**OS**
Linux

**Additional info**
https://github.com/cburroughs/example-adhoc/tree/ad-hoc-pex



Discussion:
> Confusingly, the __run.sh from the sandbox runs without error when invoked directly. so I'm not sure what is going on here.

Running on 2.17.0.dev2 (which also fails, in the same way) has better behaviour: running `__run.sh` fails in the same way. I think this will be #18582: before that, manually running `__run.sh` would inherit the shell's env too, which likely has a `python3.9` on `PATH`.
Thanks for the clue, @huonw. ~I'm assuming we're not correctly propagating `PATH` from the `RunRequest`, which should have `python3.9` present.~ I'll investigate shortly.
@cburroughs In the meantime, note that `python_source` targets work as `runnable` targets. Generally speaking, you shouldn't need to use `pex_binary` as an intermediate target.

Resolving patch:
diff --git a/src/python/pants/backend/python/goals/package_pex_binary.py b/src/python/pants/backend/python/goals/package_pex_binary.py
--- a/src/python/pants/backend/python/goals/package_pex_binary.py
+++ b/src/python/pants/backend/python/goals/package_pex_binary.py
@@ -115,12 +115,24 @@ def generate_additional_args(self, pex_binary_defaults: PexBinaryDefaults) -> Tu
         return tuple(args)
 
 
+@dataclass(frozen=True)
+class PexFromTargetsRequestForBuiltPackage:
+    """An intermediate class that gives consumers access to the data used to create a
+    `PexFromTargetsRequest` to fulfil a `BuiltPackage` request.
+
+    This class is used directly by `run_pex_binary`, but should be handled transparently by direct
+    `BuiltPackage` requests.
+    """
+
+    request: PexFromTargetsRequest
+
+
 @rule(level=LogLevel.DEBUG)
 async def package_pex_binary(
     field_set: PexBinaryFieldSet,
     pex_binary_defaults: PexBinaryDefaults,
     union_membership: UnionMembership,
-) -> BuiltPackage:
+) -> PexFromTargetsRequestForBuiltPackage:
     resolved_pex_entry_point_get = Get(
         ResolvedPexEntryPoint, ResolvePexEntryPointRequest(field_set.entry_point)
     )
@@ -163,25 +175,32 @@ async def package_pex_binary(
         CompletePlatforms, PexCompletePlatformsField, field_set.complete_platforms
     )
 
-    pex = await Get(
-        Pex,
-        PexFromTargetsRequest(
-            addresses=[field_set.address],
-            internal_only=False,
-            main=resolved_entry_point.val or field_set.script.value,
-            inject_args=field_set.args.value or [],
-            inject_env=field_set.env.value or FrozenDict[str, str](),
-            platforms=PexPlatforms.create_from_platforms_field(field_set.platforms),
-            complete_platforms=complete_platforms,
-            output_filename=output_filename,
-            layout=PexLayout(field_set.layout.value),
-            additional_args=field_set.generate_additional_args(pex_binary_defaults),
-            include_requirements=field_set.include_requirements.value,
-            include_source_files=field_set.include_sources.value,
-            include_local_dists=True,
-        ),
+    request = PexFromTargetsRequest(
+        addresses=[field_set.address],
+        internal_only=False,
+        main=resolved_entry_point.val or field_set.script.value,
+        inject_args=field_set.args.value or [],
+        inject_env=field_set.env.value or FrozenDict[str, str](),
+        platforms=PexPlatforms.create_from_platforms_field(field_set.platforms),
+        complete_platforms=complete_platforms,
+        output_filename=output_filename,
+        layout=PexLayout(field_set.layout.value),
+        additional_args=field_set.generate_additional_args(pex_binary_defaults),
+        include_requirements=field_set.include_requirements.value,
+        include_source_files=field_set.include_sources.value,
+        include_local_dists=True,
     )
-    return BuiltPackage(pex.digest, (BuiltPackageArtifact(output_filename),))
+
+    return PexFromTargetsRequestForBuiltPackage(request)
+
+
+@rule
+async def built_pacakge_for_pex_from_targets_request(
+    request: PexFromTargetsRequestForBuiltPackage,
+) -> BuiltPackage:
+    pft_request = request.request
+    pex = await Get(Pex, PexFromTargetsRequest, pft_request)
+    return BuiltPackage(pex.digest, (BuiltPackageArtifact(pft_request.output_filename),))
 
 
 def rules():
diff --git a/src/python/pants/backend/python/goals/run_pex_binary.py b/src/python/pants/backend/python/goals/run_pex_binary.py
--- a/src/python/pants/backend/python/goals/run_pex_binary.py
+++ b/src/python/pants/backend/python/goals/run_pex_binary.py
@@ -3,8 +3,13 @@
 
 import os
 
-from pants.backend.python.goals.package_pex_binary import PexBinaryFieldSet
+from pants.backend.python.goals.package_pex_binary import (
+    PexBinaryFieldSet,
+    PexFromTargetsRequestForBuiltPackage,
+)
 from pants.backend.python.target_types import PexLayout
+from pants.backend.python.util_rules.pex_environment import PythonExecutable
+from pants.backend.python.util_rules.pex_from_targets import InterpreterConstraintsRequest
 from pants.core.goals.package import BuiltPackage
 from pants.core.goals.run import RunRequest
 from pants.engine.rules import Get, collect_rules, rule
@@ -13,7 +18,17 @@
 
 @rule(level=LogLevel.DEBUG)
 async def create_pex_binary_run_request(field_set: PexBinaryFieldSet) -> RunRequest:
-    built_pex = await Get(BuiltPackage, PexBinaryFieldSet, field_set)
+    pex_request = await Get(PexFromTargetsRequestForBuiltPackage, PexBinaryFieldSet, field_set)
+    built_pex = await Get(BuiltPackage, PexFromTargetsRequestForBuiltPackage, pex_request)
+
+    # We need a Python executable to fulfil `adhoc_tool`/`runnable_dependency` requests
+    # as sandboxed processes will not have a `python` available on the `PATH`.
+    python = await Get(
+        PythonExecutable,
+        InterpreterConstraintsRequest,
+        pex_request.request.to_interpreter_constraints_request(),
+    )
+
     relpath = built_pex.artifacts[0].relpath
     assert relpath is not None
     if field_set.layout.value != PexLayout.ZIPAPP.value:
@@ -21,7 +36,7 @@ async def create_pex_binary_run_request(field_set: PexBinaryFieldSet) -> RunRequ
 
     return RunRequest(
         digest=built_pex.digest,
-        args=[os.path.join("{chroot}", relpath)],
+        args=[python.path, os.path.join("{chroot}", relpath)],
     )
 
 


Test patch:
diff --git a/src/python/pants/backend/adhoc/adhoc_tool_integration_test.py b/src/python/pants/backend/adhoc/adhoc_tool_integration_test.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/backend/adhoc/adhoc_tool_integration_test.py
@@ -0,0 +1,41 @@
+# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from textwrap import dedent
+
+from pants.testutil.pants_integration_test import run_pants, setup_tmpdir
+
+
+def test_pex_binary() -> None:
+    sources = {
+        "src/hello.py": dedent(
+            """\
+            print("Hello, World!")
+            """
+        ),
+        "src/BUILD": dedent(
+            """\
+            python_sources()
+
+            pex_binary(
+                name="pex",
+                entry_point="hello.py",
+            )
+
+            adhoc_tool(
+                name="adhoc",
+                runnable=":pex",
+            )
+            """
+        ),
+    }
+
+    with setup_tmpdir(sources) as tmpdir:
+        args = [
+            "--backend-packages=['pants.backend.experimental.adhoc', 'pants.backend.python',]",
+            f"--source-root-patterns=['{tmpdir}/src']",
+            "export-codegen",
+            f"{tmpdir}/src:adhoc",
+        ]
+        result = run_pants(args)
+        assert result.exit_code == 0