Repository: pantsbuild/pants

Issue:
Add a `system_binary` target for use as dependencies for `experimental_run_in_sandbox`/`shell_command`
Currently, `shell_command` allows you to find binaries that are already on the system `PATH` (or other shell search paths if provided). This is fine for system/POSIX tools, but if it's a binary whose behaviour changes significantly with different versions, it would be useful to test that the binary's output matches a regexp.

Suggest the target would look like this:

```
system_binary(
  binary_name="python3",
  test_args=["--version"],
  output_pattern=r"3\.([8-9]|([1-9][0-9]+))\.[0-9]+",
  search_paths= # OPTIONAL_VALUE
)
```

Each `python3` binary found would be run with `--version`, and tested for the `output_pattern`, the response would include the path to the binary, but also the specific value of the `output_pattern` for caching purposes.




Discussion:
Is this overlapping with https://github.com/pantsbuild/pants/issues/17781 (and/or replacing it with a better idea)?
@huonw It's close enough to #17781 that I'd want to merge the two tickets. I think the `--version` test is important for things that _don't_ live in the system.
Closed in favour of #17781
Re-opening. On re-read, I think that #17781 is a combination of this ticket and #18320.

Resolving patch:
diff --git a/src/python/pants/backend/adhoc/run_system_binary.py b/src/python/pants/backend/adhoc/run_system_binary.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/backend/adhoc/run_system_binary.py
@@ -0,0 +1,118 @@
+# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass
+from typing import Iterable
+
+from pants.backend.adhoc.target_types import (
+    SystemBinaryExtraSearchPathsField,
+    SystemBinaryFingerprintArgsField,
+    SystemBinaryFingerprintPattern,
+    SystemBinaryNameField,
+)
+from pants.core.goals.run import RunFieldSet, RunInSandboxBehavior, RunRequest
+from pants.core.util_rules.system_binaries import (
+    SEARCH_PATHS,
+    BinaryPath,
+    BinaryPathRequest,
+    BinaryPaths,
+    BinaryPathTest,
+)
+from pants.engine.internals.native_engine import EMPTY_DIGEST
+from pants.engine.internals.selectors import Get
+from pants.engine.rules import collect_rules, rule, rule_helper
+from pants.util.logging import LogLevel
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class SystemBinaryFieldSet(RunFieldSet):
+    run_in_sandbox_behavior = RunInSandboxBehavior.RUN_REQUEST_HERMETIC
+
+    required_fields = (
+        SystemBinaryNameField,
+        SystemBinaryExtraSearchPathsField,
+        SystemBinaryFingerprintPattern,
+        SystemBinaryFingerprintArgsField,
+    )
+
+    name: SystemBinaryNameField
+    extra_search_paths: SystemBinaryExtraSearchPathsField
+    fingerprint_pattern: SystemBinaryFingerprintPattern
+    fingerprint_argv: SystemBinaryFingerprintArgsField
+
+
+@rule_helper
+async def _find_binary(
+    binary_name: str,
+    extra_search_paths: Iterable[str],
+    fingerprint_pattern: str | None,
+    fingerprint_args: tuple[str, ...] | None,
+) -> BinaryPath:
+
+    test = (
+        BinaryPathTest(fingerprint_args or (), fingerprint_stdout=False)
+        if fingerprint_pattern
+        else None
+    )
+
+    search_paths = tuple(extra_search_paths) + SEARCH_PATHS
+
+    binaries = await Get(
+        BinaryPaths,
+        BinaryPathRequest(
+            binary_name=binary_name,
+            search_path=search_paths,
+            test=test,
+        ),
+    )
+
+    for binary in binaries.paths:
+        if fingerprint_pattern:
+            fingerprint = binary.fingerprint.strip()
+            match = re.match(fingerprint_pattern, fingerprint)
+            if not match:
+                continue
+
+        return binary
+
+    raise ValueError(
+        f"Could not find a binary with name `{binary_name}`"
+        + (
+            ""
+            if not fingerprint_pattern
+            else f" with output matching `{fingerprint_pattern}` when run with arguments `{' '.join(fingerprint_args or ())}`"
+        )
+        + f". The following paths were searched: {', '.join(search_paths)}."
+    )
+
+
+@rule(level=LogLevel.DEBUG)
+async def create_system_binary_run_request(field_set: SystemBinaryFieldSet) -> RunRequest:
+
+    assert field_set.name.value is not None
+    extra_search_paths = field_set.extra_search_paths.value or ()
+
+    path = await _find_binary(
+        field_set.name.value,
+        extra_search_paths,
+        field_set.fingerprint_pattern.value,
+        field_set.fingerprint_argv.value,
+    )
+
+    return RunRequest(
+        digest=EMPTY_DIGEST,
+        args=[path.path],
+    )
+
+
+def rules():
+    return [
+        *collect_rules(),
+        *SystemBinaryFieldSet.rules(),
+    ]
diff --git a/src/python/pants/backend/adhoc/target_types.py b/src/python/pants/backend/adhoc/target_types.py
--- a/src/python/pants/backend/adhoc/target_types.py
+++ b/src/python/pants/backend/adhoc/target_types.py
@@ -246,3 +246,69 @@ class AdhocToolTarget(Target):
             shell_sources(name="scripts")
         """
     )
+
+
+# ---
+# `system_binary` target
+# ---
+
+
+class SystemBinaryNameField(StringField):
+    alias = "binary_name"
+    required = True
+    help = "The name of the binary to find."
+
+
+class SystemBinaryExtraSearchPathsField(StringSequenceField):
+    alias = "extra_search_paths"
+    default = ()
+    help = help_text(
+        """
+        Extra search paths to look for the binary. These take priority over Pants' default
+        search paths.
+        """
+    )
+
+
+class SystemBinaryFingerprintPattern(StringField):
+    alias = "fingerprint"
+    required = False
+    default = None
+    help = help_text(
+        """
+        A regular expression which will be used to match the fingerprint outputs from
+        candidate binaries found during the search process.
+        """
+    )
+
+
+class SystemBinaryFingerprintArgsField(StringSequenceField):
+    alias = "fingerprint_args"
+    default = ()
+    help = help_text(
+        "Specifies arguments that will be used to run the binary during the search process."
+    )
+
+
+class SystemBinaryTarget(Target):
+    alias = "system_binary"
+    core_fields = (
+        *COMMON_TARGET_FIELDS,
+        SystemBinaryNameField,
+        SystemBinaryExtraSearchPathsField,
+        SystemBinaryFingerprintPattern,
+        SystemBinaryFingerprintArgsField,
+    )
+    help = help_text(
+        lambda: f"""
+        A system binary that can be run with `pants run` or consumed by `{AdhocToolTarget.alias}`.
+
+        Pants will search for binaries with name `{SystemBinaryNameField.alias}` in the search
+        paths provided, as well as default search paths. If
+        `{SystemBinaryFingerprintPattern.alias}` is specified, each binary that is located will be
+        executed with the arguments from `{SystemBinaryFingerprintArgsField.alias}`. Any binaries
+        whose output does not match the pattern will be excluded.
+
+        The first non-excluded binary will be the one that is resolved.
+        """
+    )
diff --git a/src/python/pants/backend/experimental/adhoc/register.py b/src/python/pants/backend/experimental/adhoc/register.py
--- a/src/python/pants/backend/experimental/adhoc/register.py
+++ b/src/python/pants/backend/experimental/adhoc/register.py
@@ -1,17 +1,19 @@
 # Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
-from pants.backend.adhoc import adhoc_tool
-from pants.backend.adhoc.target_types import AdhocToolTarget
+from pants.backend.adhoc import adhoc_tool, run_system_binary
+from pants.backend.adhoc.target_types import AdhocToolTarget, SystemBinaryTarget
 
 
 def target_types():
     return [
         AdhocToolTarget,
+        SystemBinaryTarget,
     ]
 
 
 def rules():
     return [
         *adhoc_tool.rules(),
+        *run_system_binary.rules(),
     ]


Test patch:
diff --git a/src/python/pants/backend/adhoc/run_system_binary_integration_test.py b/src/python/pants/backend/adhoc/run_system_binary_integration_test.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/backend/adhoc/run_system_binary_integration_test.py
@@ -0,0 +1,93 @@
+# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from textwrap import dedent
+
+import pytest
+
+from pants.testutil.pants_integration_test import run_pants, setup_tmpdir
+
+
+def test_system_binary_and_adhoc_tool() -> None:
+    sources = {
+        "src/test_file.txt": dedent(
+            """\
+            I am a duck.
+            """
+        ),
+        "src/BUILD": dedent(
+            """\
+            files(name="files", sources=["*.txt",])
+
+            system_binary(
+                name="cat",
+                binary_name="cat",
+            )
+
+            adhoc_tool(
+                name="adhoc",
+                runnable=":cat",
+                execution_dependencies=[":files",],
+                args=["test_file.txt",],
+                log_output=True,
+                stdout="stdout",
+            )
+            """
+        ),
+    }
+
+    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 "[INFO] I am a duck." in result.stderr.strip()
+
+
+@pytest.mark.parametrize(
+    ("fingerprint,passes"),
+    (
+        (r"Binary Name v6\.32\.1", True),
+        (r"(.*)v6\.(.*)", True),
+        (r"Binary Name v6\.99999\.1", False),
+    ),
+)
+def test_fingerprint(fingerprint: str, passes: bool) -> None:
+    sources = {
+        "src/BUILD": dedent(
+            f"""\
+            system_binary(
+                name="bash",
+                binary_name="bash",
+                fingerprint=r"{fingerprint}",
+                fingerprint_args=("-c", "echo Binary Name v6.32.1",),
+            )
+
+            adhoc_tool(
+                name="adhoc",
+                runnable=":bash",
+                args=["-c","echo I am a duck!"],
+                log_output=True,
+                stdout="stdout",
+            )
+            """
+        ),
+    }
+
+    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)
+        if passes:
+            assert result.exit_code == 0
+            assert "[INFO] I am a duck!" in result.stderr.strip()
+        else:
+            assert result.exit_code != 0
+            assert "Could not find a binary with name `bash`" in result.stderr.strip()