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

pantsbuild / pants / 22285099215

22 Feb 2026 08:52PM UTC coverage: 75.854% (-17.1%) from 92.936%
22285099215

Pull #23121

github

web-flow
Merge c7299df9c into ba8359840
Pull Request #23121: fix issue with optional fields in dependency validator

28 of 29 new or added lines in 2 files covered. (96.55%)

11174 existing lines in 400 files now uncovered.

53694 of 70786 relevant lines covered (75.85%)

1.88 hits per line

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

69.92
/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
2✔
5

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

11
from pants.backend.shell.subsystems.shell_setup import ShellSetup
2✔
12
from pants.backend.shell.target_types import (
2✔
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
2✔
36
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
2✔
37
from pants.core.environments.rules import EnvironmentNameRequest, resolve_environment_name
2✔
38
from pants.core.environments.target_types import EnvironmentTarget
2✔
39
from pants.core.goals.run import RunFieldSet, RunInSandboxBehavior, RunRequest
2✔
40
from pants.core.target_types import FileSourceField
2✔
41
from pants.core.util_rules.adhoc_process_support import (
2✔
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
2✔
53
from pants.core.util_rules.system_binaries import (
2✔
54
    BashBinary,
55
    BinaryShimsRequest,
56
    create_binary_shims,
57
)
58
from pants.engine.environment import EnvironmentName
2✔
59
from pants.engine.fs import PathGlobs
2✔
60
from pants.engine.internals.graph import resolve_target
2✔
61
from pants.engine.internals.native_engine import EMPTY_DIGEST
2✔
62
from pants.engine.intrinsics import digest_to_snapshot
2✔
63
from pants.engine.process import Process
2✔
64
from pants.engine.rules import collect_rules, implicitly, rule
2✔
65
from pants.engine.target import (
2✔
66
    GeneratedSources,
67
    GenerateSourcesRequest,
68
    Target,
69
    WrappedTargetRequest,
70
)
71
from pants.engine.unions import UnionRule
2✔
72
from pants.util.docutil import bin_name
2✔
73
from pants.util.frozendict import FrozenDict
2✔
74
from pants.util.logging import LogLevel
2✔
75

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

78

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

83

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

88

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

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

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

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

107
    execution_environment = await resolve_execution_environment(
2✔
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
2✔
117

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

121
    # Resolve the `tools` field into a digest
122
    tools = shell_command.get(ShellCommandToolsField, default_raw_value=()).value or ()
2✔
123
    tools = tuple(tool for tool in tools if tool not in BASH_BUILTIN_COMMANDS)
2✔
124
    resolved_tools = await create_binary_shims(
2✔
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
2✔
134
    extra_sandbox_contents: list[ExtraSandboxContents] = []
2✔
135
    extra_sandbox_contents.append(
2✔
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:
2✔
146
        extra_sandbox_contents.append(
1✔
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(
2✔
157
        MergeExtraSandboxContents(tuple(extra_sandbox_contents))
158
    )
159

160
    env_vars = await prepare_env_vars(
2✔
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 = {
2✔
169
        **merged_extras.append_only_caches,
170
        **(shell_command.get(ShellCommandNamedCachesField).value or {}),
171
    }
172

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

178
    workspace_invalidation_globs: PathGlobs | None = None
2✔
179
    workspace_invalidation_sources = (
2✔
180
        shell_command.get(ShellCommandWorkspaceInvalidationSourcesField).value or ()
181
    )
182
    if workspace_invalidation_sources:
2✔
UNCOV
183
        spec_path = shell_command.address.spec_path
×
UNCOV
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
2✔
191

192
    return AdhocProcessRequest(
2✔
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):
2✔
218
    required_fields = (
2✔
219
        RunShellCommandCommandField,
220
        RunShellCommandWorkdirField,
221
    )
222
    run_in_sandbox_behavior = RunInSandboxBehavior.NOT_SUPPORTED
2✔
223

224

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

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

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

238

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

249
    adhoc_result = await convert_fallible_adhoc_process_result(
1✔
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)
1✔
261
    return GeneratedSources(output)
1✔
262

263

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

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

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

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

UNCOV
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
    )
UNCOV
290
    dependencies_digest = execution_environment.digest
×
291

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

UNCOV
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
2✔
309
async def run_shell_command_request(bash: BashBinary, shell_command: RunShellCommand) -> RunRequest:
2✔
UNCOV
310
    wrapped_tgt = await resolve_target(
×
311
        WrappedTargetRequest(shell_command.address, description_of_origin="<infallible>"),
312
        **implicitly(),
313
    )
UNCOV
314
    process = await _interactive_shell_command(wrapped_tgt.target, bash)
×
UNCOV
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)
2✔
323
async def run_shell_command_build_request(
2✔
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

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

362
    # Add tools to the environment
UNCOV
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
UNCOV
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

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

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

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

UNCOV
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():
2✔
420
    return [
2✔
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(
2✔
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