• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 20772612304

07 Jan 2026 06:21AM UTC coverage: 78.855% (-1.4%) from 80.283%
20772612304

Pull #22981

github

web-flow
Merge b6d12d53b into 4414e420c
Pull Request #22981: make `shell_command` targets be runnable via the `run` goal

44 of 66 new or added lines in 3 files covered. (66.67%)

915 existing lines in 53 files now uncovered.

74296 of 94219 relevant lines covered (78.85%)

3.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

44.36
/src/python/pants/backend/shell/util_rules/shell_command.py
1
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
6✔
5

6
import logging
6✔
7
import os
6✔
8
import shlex
6✔
9
from dataclasses import dataclass
6✔
10

11
from pants.backend.shell.subsystems.shell_setup import ShellSetup
6✔
12
from pants.backend.shell.target_types import (
6✔
13
    RunShellCommandCommandField,
14
    RunShellCommandWorkdirField,
15
    ShellCommandCacheScopeField,
16
    ShellCommandCommandField,
17
    ShellCommandCommandFieldBase,
18
    ShellCommandExecutionDependenciesField,
19
    ShellCommandExtraEnvVarsField,
20
    ShellCommandLogOutputField,
21
    ShellCommandNamedCachesField,
22
    ShellCommandOutputDirectoriesField,
23
    ShellCommandOutputFilesField,
24
    ShellCommandOutputRootDirField,
25
    ShellCommandOutputsMatchMode,
26
    ShellCommandPathEnvModifyModeField,
27
    ShellCommandRunnableDependenciesField,
28
    ShellCommandSourcesField,
29
    ShellCommandTarget,
30
    ShellCommandTimeoutField,
31
    ShellCommandToolsField,
32
    ShellCommandWorkdirField,
33
    ShellCommandWorkspaceInvalidationSourcesField,
34
)
35
from pants.backend.shell.util_rules.builtin import BASH_BUILTIN_COMMANDS
6✔
36
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
6✔
37
from pants.core.environments.rules import EnvironmentNameRequest, resolve_environment_name
6✔
38
from pants.core.environments.target_types import EnvironmentTarget
6✔
39
from pants.core.goals.run import RunFieldSet, RunInSandboxBehavior, RunRequest
6✔
40
from pants.core.target_types import FileSourceField
6✔
41
from pants.core.util_rules.adhoc_process_support import (
6✔
42
    AdhocProcessRequest,
43
    ExtraSandboxContents,
44
    MergeExtraSandboxContents,
45
    ResolveExecutionDependenciesRequest,
46
    convert_fallible_adhoc_process_result,
47
    merge_extra_sandbox_contents,
48
    parse_relative_directory,
49
    prepare_env_vars,
50
    resolve_execution_environment,
51
)
52
from pants.core.util_rules.adhoc_process_support import rules as adhoc_process_support_rules
6✔
53
from pants.core.util_rules.system_binaries import (
6✔
54
    BashBinary,
55
    BinaryShimsRequest,
56
    create_binary_shims,
57
)
58
from pants.engine.environment import EnvironmentName
6✔
59
from pants.engine.fs import PathGlobs
6✔
60
from pants.engine.internals.graph import resolve_target
6✔
61
from pants.engine.internals.native_engine import EMPTY_DIGEST
6✔
62
from pants.engine.intrinsics import digest_to_snapshot
6✔
63
from pants.engine.process import Process
6✔
64
from pants.engine.rules import collect_rules, implicitly, rule
6✔
65
from pants.engine.target import (
6✔
66
    GeneratedSources,
67
    GenerateSourcesRequest,
68
    Target,
69
    WrappedTargetRequest,
70
)
71
from pants.engine.unions import UnionRule
6✔
72
from pants.util.docutil import bin_name
6✔
73
from pants.util.frozendict import FrozenDict
6✔
74
from pants.util.logging import LogLevel
6✔
75

76
logger = logging.getLogger(__name__)
6✔
77

78

79
class GenerateFilesFromShellCommandRequest(GenerateSourcesRequest):
6✔
80
    input = ShellCommandSourcesField
6✔
81
    output = FileSourceField
6✔
82

83

84
@dataclass(frozen=True)
6✔
85
class ShellCommandProcessFromTargetRequest:
6✔
86
    target: Target
6✔
87

88

89
@rule
6✔
90
async def prepare_process_request_from_target(
6✔
91
    request: ShellCommandProcessFromTargetRequest,
92
    shell_setup: ShellSetup.EnvironmentAware,
93
    bash: BashBinary,
94
    env_target: EnvironmentTarget,
95
) -> AdhocProcessRequest:
96
    shell_command = request.target
×
97

98
    description = f"the `{shell_command.alias}` at `{shell_command.address}`"
×
99

100
    working_directory = shell_command[ShellCommandWorkdirField].value
×
101
    assert working_directory is not None, "working_directory should always be a string"
×
102

NEW
103
    command = shell_command[ShellCommandCommandFieldBase].value
×
104
    if not command:
×
105
        raise ValueError(f"Missing `command` line in `{description}.")
×
106

107
    execution_environment = await resolve_execution_environment(
×
108
        ResolveExecutionDependenciesRequest(
109
            shell_command.address,
110
            shell_command.get(ShellCommandExecutionDependenciesField).value,
111
            shell_command.get(ShellCommandRunnableDependenciesField).value,
112
        ),
113
        **implicitly(),
114
    )
115

116
    dependencies_digest = execution_environment.digest
×
117

118
    output_files = shell_command.get(ShellCommandOutputFilesField).value or ()
×
119
    output_directories = shell_command.get(ShellCommandOutputDirectoriesField).value or ()
×
120

121
    # Resolve the `tools` field into a digest
122
    tools = shell_command.get(ShellCommandToolsField, default_raw_value=()).value or ()
×
123
    tools = tuple(tool for tool in tools if tool not in BASH_BUILTIN_COMMANDS)
×
124
    resolved_tools = await create_binary_shims(
×
125
        BinaryShimsRequest.for_binaries(
126
            *tools,
127
            rationale=f"execute {description}",
128
            search_path=shell_setup.executable_search_path,
129
        ),
130
        bash,
131
    )
132

133
    runnable_dependencies = execution_environment.runnable_dependencies
×
134
    extra_sandbox_contents: list[ExtraSandboxContents] = []
×
135
    extra_sandbox_contents.append(
×
136
        ExtraSandboxContents(
137
            digest=EMPTY_DIGEST,
138
            paths=(resolved_tools.path_component,),
139
            immutable_input_digests=FrozenDict(resolved_tools.immutable_input_digests or {}),
140
            append_only_caches=FrozenDict(),
141
            extra_env=FrozenDict(),
142
        )
143
    )
144

145
    if runnable_dependencies:
×
146
        extra_sandbox_contents.append(
×
147
            ExtraSandboxContents(
148
                digest=EMPTY_DIGEST,
149
                paths=(f"{{chroot}}/{runnable_dependencies.path_component}",),
150
                immutable_input_digests=runnable_dependencies.immutable_input_digests,
151
                append_only_caches=runnable_dependencies.append_only_caches,
152
                extra_env=runnable_dependencies.extra_env,
153
            )
154
        )
155

156
    merged_extras = await merge_extra_sandbox_contents(
×
157
        MergeExtraSandboxContents(tuple(extra_sandbox_contents))
158
    )
159

160
    env_vars = await prepare_env_vars(
×
161
        merged_extras.extra_env,
162
        shell_command.get(ShellCommandExtraEnvVarsField).value or (),
163
        extra_paths=merged_extras.paths,
164
        path_env_modify_mode=shell_command.get(ShellCommandPathEnvModifyModeField).enum_value,
165
        description_of_origin=f"`{ShellCommandExtraEnvVarsField.alias}` for `shell_command` target at `{shell_command.address}`",
166
    )
167

168
    append_only_caches = {
×
169
        **merged_extras.append_only_caches,
170
        **(shell_command.get(ShellCommandNamedCachesField).value or {}),
171
    }
172

173
    cache_scope = env_target.default_cache_scope
×
174
    maybe_override_cache_scope = shell_command.get(ShellCommandCacheScopeField).enum_value
×
175
    if maybe_override_cache_scope is not None:
×
176
        cache_scope = maybe_override_cache_scope
×
177

178
    workspace_invalidation_globs: PathGlobs | None = None
×
179
    workspace_invalidation_sources = (
×
180
        shell_command.get(ShellCommandWorkspaceInvalidationSourcesField).value or ()
181
    )
182
    if workspace_invalidation_sources:
×
183
        spec_path = shell_command.address.spec_path
×
184
        workspace_invalidation_globs = PathGlobs(
×
185
            globs=(os.path.join(spec_path, glob) for glob in workspace_invalidation_sources),
186
            glob_match_error_behavior=GlobMatchErrorBehavior.error,
187
            description_of_origin=f"`{ShellCommandWorkspaceInvalidationSourcesField.alias}` for `shell_command` target at `{shell_command.address}`",
188
        )
189

190
    outputs_match_mode = shell_command.get(ShellCommandOutputsMatchMode).enum_value
×
191

192
    return AdhocProcessRequest(
×
193
        description=description,
194
        address=shell_command.address,
195
        working_directory=working_directory,
196
        root_output_directory=shell_command.get(ShellCommandOutputRootDirField).value or "",
197
        argv=(bash.path, "-c", command, shell_command.address.spec),
198
        timeout=shell_command.get(ShellCommandTimeoutField).value,
199
        input_digest=dependencies_digest,
200
        output_files=output_files,
201
        output_directories=output_directories,
202
        append_only_caches=FrozenDict(append_only_caches),
203
        env_vars=env_vars,
204
        immutable_input_digests=FrozenDict.frozen(merged_extras.immutable_input_digests),
205
        log_on_process_errors=_LOG_ON_PROCESS_ERRORS,
206
        log_output=shell_command[ShellCommandLogOutputField].value,
207
        capture_stdout_file=None,
208
        capture_stderr_file=None,
209
        workspace_invalidation_globs=workspace_invalidation_globs,
210
        cache_scope=cache_scope,
211
        use_working_directory_as_base_for_output_captures=env_target.use_working_directory_as_base_for_output_captures,
212
        outputs_match_error_behavior=outputs_match_mode.glob_match_error_behavior,
213
        outputs_match_conjunction=outputs_match_mode.glob_expansion_conjunction,
214
    )
215

216

217
class RunShellCommand(RunFieldSet):
6✔
218
    required_fields = (
6✔
219
        RunShellCommandCommandField,
220
        RunShellCommandWorkdirField,
221
    )
222
    run_in_sandbox_behavior = RunInSandboxBehavior.NOT_SUPPORTED
6✔
223

224

225
@dataclass(frozen=True)
6✔
226
class RunShellCommandBuild(RunFieldSet):
6✔
227
    """Run a shell_command target interactively with explicit tool dependencies."""
228

229
    required_fields = (ShellCommandCommandField,)
6✔
230
    run_in_sandbox_behavior = RunInSandboxBehavior.RUN_REQUEST_HERMETIC
6✔
231

232
    command: ShellCommandCommandField
6✔
233
    execution_dependencies: ShellCommandExecutionDependenciesField
6✔
234
    runnable_dependencies: ShellCommandRunnableDependenciesField
6✔
235
    tools: ShellCommandToolsField
6✔
236
    workdir: ShellCommandWorkdirField
6✔
237

238

239
@rule(desc="Running shell command", level=LogLevel.DEBUG)
6✔
240
async def shell_command_in_sandbox(
6✔
241
    request: GenerateFilesFromShellCommandRequest,
242
) -> GeneratedSources:
243
    shell_command = request.protocol_target
×
244
    environment_name = await resolve_environment_name(
×
245
        EnvironmentNameRequest.from_target(shell_command),
246
        **implicitly(),
247
    )
248

249
    adhoc_result = await convert_fallible_adhoc_process_result(
×
250
        **implicitly(
251
            {
252
                environment_name: EnvironmentName,
253
                ShellCommandProcessFromTargetRequest(
254
                    shell_command
255
                ): ShellCommandProcessFromTargetRequest,
256
            }
257
        )
258
    )
259

260
    output = await digest_to_snapshot(adhoc_result.adjusted_digest)
×
261
    return GeneratedSources(output)
×
262

263

264
async def _interactive_shell_command(
6✔
265
    shell_command: Target,
266
    bash: BashBinary,
267
) -> Process:
268
    description = f"the `{shell_command.alias}` at `{shell_command.address}`"
×
269
    working_directory = shell_command[RunShellCommandWorkdirField].value
×
270

271
    if working_directory is None:
×
272
        raise ValueError("Working directory must be not be `None` for interactive processes.")
×
273

NEW
274
    command = shell_command[RunShellCommandCommandField].value
×
275
    if not command:
×
276
        raise ValueError(f"Missing `command` line in `{description}.")
×
277

278
    command_env = {
×
279
        "CHROOT": "{chroot}",
280
    }
281

282
    execution_environment = await resolve_execution_environment(
×
283
        ResolveExecutionDependenciesRequest(
284
            shell_command.address,
285
            shell_command.get(ShellCommandExecutionDependenciesField).value,
286
            shell_command.get(ShellCommandRunnableDependenciesField).value,
287
        ),
288
        bash,
289
    )
290
    dependencies_digest = execution_environment.digest
×
291

292
    relpath = parse_relative_directory(working_directory, shell_command.address)
×
293
    boot_script = f"cd {shlex.quote(relpath)}; " if relpath != "" else ""
×
294

295
    return Process(
×
296
        argv=(
297
            bash.path,
298
            "-c",
299
            boot_script + command,
300
            f"{bin_name()} run {shell_command.address.spec} --",
301
        ),
302
        description=f"Running {description}",
303
        env=command_env,
304
        input_digest=dependencies_digest,
305
    )
306

307

308
@rule
6✔
309
async def run_shell_command_request(bash: BashBinary, shell_command: RunShellCommand) -> RunRequest:
6✔
310
    wrapped_tgt = await resolve_target(
×
311
        WrappedTargetRequest(shell_command.address, description_of_origin="<infallible>"),
312
        **implicitly(),
313
    )
314
    process = await _interactive_shell_command(wrapped_tgt.target, bash)
×
315
    return RunRequest(
×
316
        digest=process.input_digest,
317
        args=process.argv,
318
        extra_env=process.env,
319
    )
320

321

322
@rule(desc="Running shell_command target", level=LogLevel.DEBUG)
6✔
323
async def run_shell_command_build_request(
6✔
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

NEW
330
    command = field_set.command.value
×
NEW
331
    if not command:
×
NEW
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
NEW
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
NEW
347
    tools = field_set.tools.value or ()
×
NEW
348
    tools = tuple(tool for tool in tools if tool not in BASH_BUILTIN_COMMANDS)
×
NEW
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
NEW
359
    runnable_dependencies = execution_environment.runnable_dependencies
×
NEW
360
    extra_sandbox_contents: list[ExtraSandboxContents] = []
×
361

362
    # Add tools to the environment
NEW
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
NEW
374
    if runnable_dependencies:
×
NEW
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

NEW
385
    merged_extras = await merge_extra_sandbox_contents(
×
386
        MergeExtraSandboxContents(tuple(extra_sandbox_contents))
387
    )
388

389
    # Prepare environment variables
NEW
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
NEW
398
    working_directory = field_set.workdir.value
×
NEW
399
    if working_directory is None:
×
NEW
400
        working_directory = "."
×
401

NEW
402
    relpath = parse_relative_directory(working_directory, field_set.address)
×
NEW
403
    boot_script = f"cd {shlex.quote(relpath)}; " if relpath != "" else ""
×
404

NEW
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

419
def rules():
6✔
420
    return [
6✔
421
        *collect_rules(),
422
        *adhoc_process_support_rules(),
423
        UnionRule(GenerateSourcesRequest, GenerateFilesFromShellCommandRequest),
424
        *RunShellCommand.rules(),
425
        *RunShellCommandBuild.rules(),
426
    ]
427

428

429
_LOG_ON_PROCESS_ERRORS = FrozenDict(
6✔
430
    {
431
        127: (
432
            f"`{ShellCommandTarget.alias}` requires the names of any external commands used by this "
433
            f"shell command to be specified in the `{ShellCommandToolsField.alias}` field. If "
434
            f"`bash` cannot find a tool, add it to the `{ShellCommandToolsField.alias}` field."
435
        )
436
    }
437
)
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc