Skip to content

Commit 9b8cc2a

Browse files
authored
make shell_command targets be runnable via the run goal (#22981)
Teach the shell backend how to run `shell_command` targets via the `run` goal and make them valid "runnable" targets for other Pants targets which can reference runnables including `code_quality_tool`. Running a `shell_command` target can take place in the Pants execution sandbox. This PR intentionally does not remove nor deprecate `run_shell_command` and its more limited execution model of only supporting interactive execution. `run_shell_command` does not have all the fields of `shell_command` and adding such support seemed a less desirable UX change than just making `shell_command` be runnable. At the very, this PR is an incremental improvement over the existing situation and defers needing to make decision on `run_shell_command` (and the other `FOO_shell_command` target types) at this time.
1 parent 4414e42 commit 9b8cc2a

4 files changed

Lines changed: 264 additions & 14 deletions

File tree

docs/notes/2.31.x.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ Upgraded the default version of `protoc` to v30.2. Python projects should upgrad
7373

7474
#### Shell
7575

76+
`shell_command` targets are now runnable via the `run` goal and can be used as a "runnable" target by other targets including the `code_quality_tool` target type. The `run_shell_command` target type remains available at the moment. The difference between running a `shell_command` versus a `run_shell_command` target is that `shell_command` targets can be run within the Pants execution sandbox (and outputs captured) while the more limited execution model of the `run_shell_command` target type only supports interactive execution.
77+
7678
#### Javascript
7779

7880
#### TypeScript

src/python/pants/backend/shell/target_types.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ class ShellSourcesGeneratorTarget(TargetFilesGenerator):
269269
# -----------------------------------------------------------------------------------------------
270270

271271

272-
class ShellCommandCommandField(StringField):
272+
class ShellCommandCommandFieldBase(StringField):
273273
alias = "command"
274274
required = True
275275
help = help_text(
@@ -283,6 +283,10 @@ class ShellCommandCommandField(StringField):
283283
)
284284

285285

286+
class ShellCommandCommandField(ShellCommandCommandFieldBase):
287+
pass
288+
289+
286290
class ShellCommandOutputFilesField(AdhocToolOutputFilesField):
287291
pass
288292

@@ -438,7 +442,14 @@ class ShellCommandTarget(Target):
438442
)
439443
help = help_text(
440444
"""
441-
Execute any external tool for its side effects.
445+
Execute any external tool for its side effects, or run it interactively.
446+
447+
This target provides hermetic execution with explicit tool dependencies via the
448+
`tools` field. It can be used for:
449+
450+
- Code generation (produces output files consumed by other targets)
451+
- Running scripts interactively with explicit dependencies (via `pants run`)
452+
- Build-time hermetic execution (via `pants experimental_run_in_sandbox`)
442453
443454
Example BUILD file:
444455
@@ -452,27 +463,39 @@ class ShellCommandTarget(Target):
452463
453464
shell_sources(name="scripts")
454465
455-
Remember to add this target to the dependencies of each consumer, such as your
456-
`python_tests` or `docker_image`. When relevant, Pants will run your `command` and
457-
insert the `outputs` into that consumer's context.
466+
When used as a dependency of other targets (e.g., `python_tests` or `docker_image`),
467+
Pants will run your `command` and insert the `outputs` into that consumer's context.
468+
469+
When used with `pants run :target`, the command runs interactively in the workspace
470+
with all dependencies and tools available.
458471
459472
The command may be retried and/or cancelled, so ensure that it is idempotent.
473+
474+
For simpler, workspace-oriented scripts that use system PATH tools, consider
475+
`run_shell_command` instead.
460476
"""
461477
)
462478

463479

480+
class RunShellCommandCommandField(ShellCommandCommandFieldBase):
481+
pass
482+
483+
464484
class ShellCommandRunTarget(Target):
465485
alias = "run_shell_command"
466486
core_fields = (
467487
*COMMON_TARGET_FIELDS,
468488
RunShellCommandExecutionDependenciesField,
469489
RunShellCommandRunnableDependenciesField,
470-
ShellCommandCommandField,
490+
RunShellCommandCommandField,
471491
RunShellCommandWorkdirField,
472492
)
473493
help = help_text(
474494
"""
475-
Run a script in the workspace, with all dependencies packaged/copied into a chroot.
495+
Run a script in the workspace with dependencies packaged into a chroot.
496+
497+
This target is designed for quick, workspace-oriented interactive scripts that use
498+
tools from the system PATH.
476499
477500
Example BUILD file:
478501
@@ -484,10 +507,13 @@ class ShellCommandRunTarget(Target):
484507
The `command` may use either `{chroot}` on the command line, or the `$CHROOT`
485508
environment variable to get the root directory for where any dependencies are located.
486509
487-
In contrast to the `shell_command`, in addition to `workdir` you only have
488-
the `command` and `execution_dependencies` fields as the `tools` you are going to use are
489-
already on the PATH which is inherited from the Pants environment. Also, the `outputs` does
490-
not apply, as any output files produced will end up directly in your project tree.
510+
In contrast to `shell_command`, this target:
511+
- Uses tools from the system PATH (not explicit `tools` field)
512+
- Does not support `output_files` (outputs go directly to workspace)
513+
- Is simpler to use for quick workspace scripts
514+
515+
For more hermetic execution with explicit tool dependencies, consider using
516+
`shell_command` instead, which provides better reproducibility and caching.
491517
"""
492518
)
493519

src/python/pants/backend/shell/util_rules/shell_command.py

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010

1111
from pants.backend.shell.subsystems.shell_setup import ShellSetup
1212
from pants.backend.shell.target_types import (
13+
RunShellCommandCommandField,
1314
RunShellCommandWorkdirField,
1415
ShellCommandCacheScopeField,
1516
ShellCommandCommandField,
17+
ShellCommandCommandFieldBase,
1618
ShellCommandExecutionDependenciesField,
1719
ShellCommandExtraEnvVarsField,
1820
ShellCommandLogOutputField,
@@ -98,7 +100,7 @@ async def prepare_process_request_from_target(
98100
working_directory = shell_command[ShellCommandWorkdirField].value
99101
assert working_directory is not None, "working_directory should always be a string"
100102

101-
command = shell_command[ShellCommandCommandField].value
103+
command = shell_command[ShellCommandCommandFieldBase].value
102104
if not command:
103105
raise ValueError(f"Missing `command` line in `{description}.")
104106

@@ -214,12 +216,26 @@ async def prepare_process_request_from_target(
214216

215217
class RunShellCommand(RunFieldSet):
216218
required_fields = (
217-
ShellCommandCommandField,
219+
RunShellCommandCommandField,
218220
RunShellCommandWorkdirField,
219221
)
220222
run_in_sandbox_behavior = RunInSandboxBehavior.NOT_SUPPORTED
221223

222224

225+
@dataclass(frozen=True)
226+
class RunShellCommandBuild(RunFieldSet):
227+
"""Run a shell_command target interactively with explicit tool dependencies."""
228+
229+
required_fields = (ShellCommandCommandField,)
230+
run_in_sandbox_behavior = RunInSandboxBehavior.RUN_REQUEST_HERMETIC
231+
232+
command: ShellCommandCommandField
233+
execution_dependencies: ShellCommandExecutionDependenciesField
234+
runnable_dependencies: ShellCommandRunnableDependenciesField
235+
tools: ShellCommandToolsField
236+
workdir: ShellCommandWorkdirField
237+
238+
223239
@rule(desc="Running shell command", level=LogLevel.DEBUG)
224240
async def shell_command_in_sandbox(
225241
request: GenerateFilesFromShellCommandRequest,
@@ -255,7 +271,7 @@ async def _interactive_shell_command(
255271
if working_directory is None:
256272
raise ValueError("Working directory must be not be `None` for interactive processes.")
257273

258-
command = shell_command[ShellCommandCommandField].value
274+
command = shell_command[RunShellCommandCommandField].value
259275
if not command:
260276
raise ValueError(f"Missing `command` line in `{description}.")
261277

@@ -303,12 +319,110 @@ async def run_shell_command_request(bash: BashBinary, shell_command: RunShellCom
303319
)
304320

305321

322+
@rule(desc="Running shell_command target", level=LogLevel.DEBUG)
323+
async def run_shell_command_build_request(
324+
field_set: RunShellCommandBuild,
325+
bash: BashBinary,
326+
shell_setup: ShellSetup.EnvironmentAware,
327+
) -> RunRequest:
328+
"""Execute a shell_command target interactively with explicit tool dependencies."""
329+
330+
command = field_set.command.value
331+
if not command:
332+
raise ValueError(
333+
f"Missing `command` field in `shell_command` target at `{field_set.address}`."
334+
)
335+
336+
# Resolve execution environment with all dependencies
337+
execution_environment = await resolve_execution_environment(
338+
ResolveExecutionDependenciesRequest(
339+
field_set.address,
340+
field_set.execution_dependencies.value,
341+
field_set.runnable_dependencies.value,
342+
),
343+
**implicitly(),
344+
)
345+
346+
# Resolve tools into binary shims
347+
tools = field_set.tools.value or ()
348+
tools = tuple(tool for tool in tools if tool not in BASH_BUILTIN_COMMANDS)
349+
resolved_tools = await create_binary_shims(
350+
BinaryShimsRequest.for_binaries(
351+
*tools,
352+
rationale=f"execute `shell_command` at `{field_set.address}`",
353+
search_path=shell_setup.executable_search_path,
354+
),
355+
bash,
356+
)
357+
358+
# Prepare extra sandbox contents with tools and runnable dependencies
359+
runnable_dependencies = execution_environment.runnable_dependencies
360+
extra_sandbox_contents: list[ExtraSandboxContents] = []
361+
362+
# Add tools to the environment
363+
extra_sandbox_contents.append(
364+
ExtraSandboxContents(
365+
digest=EMPTY_DIGEST,
366+
paths=(resolved_tools.path_component,),
367+
immutable_input_digests=FrozenDict(resolved_tools.immutable_input_digests or {}),
368+
append_only_caches=FrozenDict(),
369+
extra_env=FrozenDict(),
370+
)
371+
)
372+
373+
# Add runnable dependencies
374+
if runnable_dependencies:
375+
extra_sandbox_contents.append(
376+
ExtraSandboxContents(
377+
digest=EMPTY_DIGEST,
378+
paths=(f"{{chroot}}/{runnable_dependencies.path_component}",),
379+
immutable_input_digests=runnable_dependencies.immutable_input_digests,
380+
append_only_caches=runnable_dependencies.append_only_caches,
381+
extra_env=runnable_dependencies.extra_env,
382+
)
383+
)
384+
385+
merged_extras = await merge_extra_sandbox_contents(
386+
MergeExtraSandboxContents(tuple(extra_sandbox_contents))
387+
)
388+
389+
# Prepare environment variables
390+
env_vars = await prepare_env_vars(
391+
merged_extras.extra_env,
392+
(), # No extra env vars field for run
393+
extra_paths=merged_extras.paths,
394+
description_of_origin=f"`shell_command` target at `{field_set.address}`",
395+
)
396+
397+
# Parse working directory
398+
working_directory = field_set.workdir.value
399+
if working_directory is None:
400+
working_directory = "."
401+
402+
relpath = parse_relative_directory(working_directory, field_set.address)
403+
boot_script = f"cd {shlex.quote(relpath)}; " if relpath != "" else ""
404+
405+
return RunRequest(
406+
digest=execution_environment.digest,
407+
args=(
408+
bash.path,
409+
"-c",
410+
boot_script + command,
411+
f"{bin_name()} run {field_set.address.spec} --",
412+
),
413+
extra_env=env_vars,
414+
immutable_input_digests=FrozenDict.frozen(merged_extras.immutable_input_digests),
415+
append_only_caches=FrozenDict.frozen(merged_extras.append_only_caches),
416+
)
417+
418+
306419
def rules():
307420
return [
308421
*collect_rules(),
309422
*adhoc_process_support_rules(),
310423
UnionRule(GenerateSourcesRequest, GenerateFilesFromShellCommandRequest),
311424
*RunShellCommand.rules(),
425+
*RunShellCommandBuild.rules(),
312426
]
313427

314428

0 commit comments

Comments
 (0)