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

pantsbuild / pants / 18812500213

26 Oct 2025 03:42AM UTC coverage: 80.284% (+0.005%) from 80.279%
18812500213

Pull #22804

github

web-flow
Merge 2a56fdb46 into 4834308dc
Pull Request #22804: test_shell_command: use correct default cache scope for a test's environment

29 of 31 new or added lines in 2 files covered. (93.55%)

1314 existing lines in 64 files now uncovered.

77900 of 97030 relevant lines covered (80.28%)

3.35 hits per line

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

73.78
/src/python/pants/core/environments/rules.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
12✔
5

6
import dataclasses
12✔
7
import logging
12✔
8
import shlex
12✔
9
from collections.abc import Callable, Iterable, Sequence
12✔
10
from dataclasses import dataclass
12✔
11
from itertools import groupby
12✔
12
from typing import Any, ClassVar, cast
12✔
13

14
from pants.build_graph.address import Address, AddressInput
12✔
15
from pants.core.environments.subsystems import EnvironmentsSubsystem
12✔
16
from pants.core.environments.target_types import (
12✔
17
    CompatiblePlatformsField,
18
    DockerEnvironmentTarget,
19
    DockerFallbackEnvironmentField,
20
    DockerImageField,
21
    DockerPlatformField,
22
    EnvironmentField,
23
    EnvironmentTarget,
24
    FallbackEnvironmentField,
25
    LocalCompatiblePlatformsField,
26
    LocalEnvironmentTarget,
27
    LocalFallbackEnvironmentField,
28
    LocalWorkspaceCompatiblePlatformsField,
29
    LocalWorkspaceEnvironmentTarget,
30
    RemoteEnvironmentTarget,
31
    RemoteExtraPlatformPropertiesField,
32
    RemoteFallbackEnvironmentField,
33
    RemotePlatformField,
34
)
35
from pants.engine.engine_aware import EngineAwareParameter
12✔
36
from pants.engine.environment import LOCAL_ENVIRONMENT_MATCHER, LOCAL_WORKSPACE_ENVIRONMENT_MATCHER
12✔
37
from pants.engine.environment import ChosenLocalEnvironmentName as ChosenLocalEnvironmentName
12✔
38
from pants.engine.environment import (
12✔
39
    ChosenLocalWorkspaceEnvironmentName as ChosenLocalWorkspaceEnvironmentName,
40
)
41
from pants.engine.environment import EnvironmentName as EnvironmentName
12✔
42
from pants.engine.internals.build_files import resolve_address
12✔
43
from pants.engine.internals.docker import DockerResolveImageRequest
12✔
44
from pants.engine.internals.graph import resolve_target_for_bootstrapping
12✔
45
from pants.engine.internals.native_engine import ProcessExecutionEnvironment
12✔
46
from pants.engine.internals.scheduler import SchedulerSession
12✔
47
from pants.engine.internals.selectors import Params
12✔
48
from pants.engine.intrinsics import docker_resolve_image
12✔
49
from pants.engine.platform import Platform
12✔
50
from pants.engine.rules import QueryRule, collect_rules, concurrently, implicitly, rule
12✔
51
from pants.engine.target import (
12✔
52
    NO_VALUE,
53
    Field,
54
    FieldDefaultFactoryRequest,
55
    FieldDefaultFactoryResult,
56
    FieldSet,
57
    SequenceField,
58
    StringField,
59
    StringSequenceField,
60
    Target,
61
    WrappedTargetRequest,
62
)
63
from pants.engine.unions import UnionRule
12✔
64
from pants.option import custom_types
12✔
65
from pants.option.global_options import GlobalOptions, KeepSandboxes
12✔
66
from pants.option.option_types import OptionInfo, collect_options_info
12✔
67
from pants.option.subsystem import Subsystem
12✔
68
from pants.util.frozendict import FrozenDict
12✔
69
from pants.util.memo import memoized
12✔
70
from pants.util.strutil import bullet_list, softwrap
12✔
71

72
logger = logging.getLogger(__name__)
12✔
73

74

75
class DockerPlatformFieldDefaultFactoryRequest(FieldDefaultFactoryRequest):
12✔
76
    field_type = DockerPlatformField
12✔
77

78

79
@rule
12✔
80
async def docker_platform_field_default_factory(
12✔
81
    _: DockerPlatformFieldDefaultFactoryRequest,
82
) -> FieldDefaultFactoryResult:
83
    return FieldDefaultFactoryResult(lambda f: f.normalized_value)
×
84

85

86
async def _warn_on_non_local_environments(specified_targets: Iterable[Target], source: str) -> None:
12✔
87
    """Raise a warning when the user runs a local-only operation against a target that expects a
88
    non-local environment.
89

90
    The `source` will be used to explain which goal is causing the issue.
91
    """
92

93
    env_names = [
2✔
94
        (target[EnvironmentField].value, target)
95
        for target in specified_targets
96
        if target.has_field(EnvironmentField)
97
    ]
98
    sorted_env_names = sorted(env_names, key=lambda en: (en[0], en[1].address.spec))
2✔
99

100
    env_names_and_targets = [
2✔
101
        (env_name, tuple(target for _, target in group))
102
        for env_name, group in groupby(sorted_env_names, lambda x: x[0])
103
    ]
104

105
    env_tgts = await concurrently(
2✔
106
        get_target_for_environment_name(
107
            **implicitly(
108
                EnvironmentNameRequest(
109
                    name,
110
                    description_of_origin=(
111
                        "the `environment` field of targets including , ".join(
112
                            tgt.address.spec for tgt in tgts[:3]
113
                        )
114
                    ),
115
                )
116
            )
117
        )
118
        for name, tgts in env_names_and_targets
119
    )
120

121
    error_cases = [
2✔
122
        (env_name, tgts, env_tgt.val)
123
        for ((env_name, tgts), env_tgt) in zip(env_names_and_targets, env_tgts)
124
        if env_tgt.val is not None and not env_tgt.can_access_local_system_paths
125
    ]
126

127
    for env_name, tgts, env_tgt in error_cases:
2✔
128
        # "Blah was called with target `//foo` which specifies…"
129
        # "Blah was called with targets `//foo`, `//bar` which specify…"
130
        # "Blah was called with targets including `//foo`, `//bar`, `//baz` (and others) which specify…"
131
        plural = len(tgts) > 1
×
132
        is_long = len(tgts) > 3
×
133
        tgt_specs = [tgt.address.spec for tgt in tgts[:3]]
×
134

135
        tgts_str = (
×
136
            ("s " if plural else " ")
137
            + ("including " if is_long else "")
138
            + ", ".join(f"`{i}`" for i in tgt_specs)
139
            + (" (and others)" if is_long else "")
140
        )
141
        end_specif = "y" if plural else "ies"
×
142

143
        logger.warning(
×
144
            f"{source.capitalize()} was called with target{tgts_str}, which specif{end_specif} "
145
            f"the environment `{env_name}`, which is a `{env_tgt.alias}`. {source.capitalize()} "
146
            "only runs in the local environment. You may experience unexpected behavior."
147
        )
148

149

150
def determine_bootstrap_environment(session: SchedulerSession) -> EnvironmentName:
12✔
151
    local_env = cast(
6✔
152
        ChosenLocalEnvironmentName,
153
        session.product_request(ChosenLocalEnvironmentName, [Params()])[0],
154
    )
155
    return local_env.val
5✔
156

157

158
class AmbiguousEnvironmentError(Exception):
12✔
159
    pass
12✔
160

161

162
class UnrecognizedEnvironmentError(Exception):
12✔
163
    pass
12✔
164

165

166
class NoFallbackEnvironmentError(Exception):
12✔
167
    pass
12✔
168

169

170
class AllEnvironmentTargets(FrozenDict[str, Target]):
12✔
171
    """A mapping of environment names to their corresponding environment target."""
172

173

174
def _compute_env_field(field_set: FieldSet) -> EnvironmentField:
12✔
175
    for attr in dir(field_set):
3✔
176
        # Skip what look like dunder methods, which are unlikely to be an
177
        # EnvironmentField value on FieldSet class declarations.
178
        if attr.startswith("__"):
3✔
179
            continue
3✔
180
        val = getattr(field_set, attr)
3✔
181
        if isinstance(val, EnvironmentField):
3✔
182
            return val
1✔
183
    return EnvironmentField(None, address=field_set.address)
3✔
184

185

186
@dataclass(frozen=True)
12✔
187
class EnvironmentNameRequest(EngineAwareParameter):
12✔
188
    f"""Normalize the value into a name from `[environments-preview].names`, such as by
12✔
189
    applying {LOCAL_ENVIRONMENT_MATCHER}."""
190

191
    raw_value: str
12✔
192
    description_of_origin: str = dataclasses.field(hash=False, compare=False)
12✔
193

194
    @classmethod
12✔
195
    def from_target(cls, target: Target) -> EnvironmentNameRequest:
12✔
196
        f"""Return a `EnvironmentNameRequest` with the environment this target should use when built.
×
197

198
        If the Target includes `EnvironmentField` in its class definition, then this method will
199
        use the value of that field. Otherwise, it will fall back to `{LOCAL_ENVIRONMENT_MATCHER}`.
200
        """
201
        env_field = target.get(EnvironmentField)
×
202
        return cls._from_field(env_field, target.address)
×
203

204
    @classmethod
12✔
205
    def from_field_set(cls, field_set: FieldSet) -> EnvironmentNameRequest:
12✔
206
        f"""Return a `EnvironmentNameRequest` with the environment this target should use when built.
2✔
207

208
        If the FieldSet includes `EnvironmentField` in its class definition, then this method will
209
        use the value of that field. Otherwise, it will fall back to `{LOCAL_ENVIRONMENT_MATCHER}`.
210

211
        Rules can then use `resolve_environment_name({{field_set.environment_name_request(): EnvironmentNameRequest}},
212
        **implicitly())` to normalize the environment value, and then pass `{{resulting_environment_name: EnvironmentName}}`
213
        into a subgraph using the `implicitly` helper to change the `EnvironmentName` used for the.
214
        """
215
        env_field = _compute_env_field(field_set)
2✔
216
        return cls._from_field(env_field, field_set.address)
2✔
217

218
    @classmethod
12✔
219
    def _from_field(cls, env_field: EnvironmentField, address: Address) -> EnvironmentNameRequest:
12✔
220
        return EnvironmentNameRequest(
2✔
221
            env_field.value,
222
            # Note that if the field was not registered, we will have fallen back to the default
223
            # LOCAL_ENVIRONMENT_MATCHER, which we expect to be infallible when normalized. That
224
            # implies that the error message using description_of_origin should not trigger, so
225
            # it's okay that the field is not actually registered on the target.
226
            description_of_origin=(f"the `{env_field.alias}` field from the target {address}"),
227
        )
228

229
    def debug_hint(self) -> str:
12✔
230
        return self.raw_value
×
231

232

233
@dataclass(frozen=True)
12✔
234
class SingleEnvironmentNameRequest(EngineAwareParameter):
12✔
235
    """Asserts that all of the given environment strings resolve to the same EnvironmentName."""
236

237
    raw_values: tuple[str, ...]
12✔
238
    description_of_origin: str = dataclasses.field(hash=False, compare=False)
12✔
239

240
    @classmethod
12✔
241
    def from_field_sets(
12✔
242
        cls, field_sets: Sequence[FieldSet], description_of_origin: str
243
    ) -> SingleEnvironmentNameRequest:
244
        """See `EnvironmentNameRequest.from_field_set`."""
245

UNCOV
246
        return SingleEnvironmentNameRequest(
1✔
247
            tuple(sorted({_compute_env_field(field_set).value for field_set in field_sets})),
248
            description_of_origin=description_of_origin,
249
        )
250

251
    def debug_hint(self) -> str:
12✔
252
        return ", ".join(self.raw_values)
×
253

254

255
@rule
12✔
256
async def determine_local_environment(
12✔
257
    all_environment_targets: AllEnvironmentTargets,
258
) -> ChosenLocalEnvironmentName:
259
    platform = Platform.create_for_localhost()
×
260
    compatible_name_and_targets = [
×
261
        (name, tgt)
262
        for name, tgt in all_environment_targets.items()
263
        if tgt.has_field(LocalCompatiblePlatformsField)
264
        and platform.value in tgt[CompatiblePlatformsField].value
265
    ]
266
    if not compatible_name_and_targets:
×
267
        # That is, use the values from the options system instead, rather than from fields.
268
        return ChosenLocalEnvironmentName(EnvironmentName(None))
×
269

270
    if len(compatible_name_and_targets) == 1:
×
271
        result_name, _tgt = compatible_name_and_targets[0]
×
272
        return ChosenLocalEnvironmentName(EnvironmentName(result_name))
×
273

274
    raise AmbiguousEnvironmentError(
×
275
        softwrap(
276
            f"""
277
            Multiple `local_environment` targets from `[environments-preview].names`
278
            are compatible with the current platform `{platform.value}`, so it is ambiguous
279
            which to use:
280
            {sorted(tgt.address.spec for _name, tgt in compatible_name_and_targets)}
281

282
            To fix, either adjust the `{CompatiblePlatformsField.alias}` field from those
283
            targets so that only one includes the value `{platform.value}`, or change
284
            `[environments-preview].names` so that it does not define some of those targets.
285

286
            It is often useful to still keep the same `local_environment` target definitions in
287
            BUILD files; instead, do not give a name to each of them in
288
            `[environments-preview].names` to avoid ambiguity. Then, you can override which target
289
            a particular name points to by overriding `[environments-preview].names`. For example,
290
            you could set this in `pants.toml`:
291

292
                [environments-preview.names]
293
                linux = "//:linux_env"
294
                macos = "//:macos_local_env"
295

296
            Then, for CI, override what the name `macos` points to by setting this in
297
            `pants.ci.toml`:
298

299
                [environments-preview.names.add]
300
                macos = "//:macos_ci_env"
301

302
            Locally, you can override `[environments-preview].names` like this by using a
303
            `.pants.rc` file, for example.
304
            """
305
        )
306
    )
307

308

309
@rule
12✔
310
async def determine_local_workspace_environment(
12✔
311
    all_environment_targets: AllEnvironmentTargets,
312
) -> ChosenLocalWorkspaceEnvironmentName:
313
    platform = Platform.create_for_localhost()
×
314
    compatible_name_and_targets = [
×
315
        (name, tgt)
316
        for name, tgt in all_environment_targets.items()
317
        if tgt.has_field(LocalWorkspaceCompatiblePlatformsField)
318
        and platform.value in tgt[CompatiblePlatformsField].value
319
    ]
320

321
    if not compatible_name_and_targets:
×
322
        # Raise an exception since, unlike with `local_environment`, a `experimental_workspace_environment`
323
        # cannot be configured via global options.
324
        raise AmbiguousEnvironmentError(
×
325
            softwrap(
326
                f"""
327
                A target requested a compatible workspace environment via the
328
                `{LOCAL_WORKSPACE_ENVIRONMENT_MATCHER}` special environment name. No `experimental_workspace_environment`
329
                target exists, however, to satisfy that request.
330

331
                Unlike local environmnts, with workspace environments, at least one `experimental_workspace_environment`
332
                target must exist and be named in the `[environments-preview.names]` option.
333
                """
334
            )
335
        )
336

337
    if len(compatible_name_and_targets) == 1:
×
338
        result_name, _tgt = compatible_name_and_targets[0]
×
339
        return ChosenLocalWorkspaceEnvironmentName(EnvironmentName(result_name))
×
340

341
    raise AmbiguousEnvironmentError(
×
342
        softwrap(
343
            f"""
344
            Multiple `experimental_workspace_environment` targets from `[environments-preview].names`
345
            are compatible with the current platform `{platform.value}`, so it is ambiguous
346
            which to use:
347
            {sorted(tgt.address.spec for _name, tgt in compatible_name_and_targets)}
348

349
            To fix, either adjust the `{CompatiblePlatformsField.alias}` field from those
350
            targets so that only one includes the value `{platform.value}`, or change
351
            `[environments-preview].names` so that it does not define some of those targets.
352

353
            It is often useful to still keep the same `experimental_workspace_environment` target definitions in
354
            BUILD files; instead, do not give a name to each of them in
355
            `[environments-preview].names` to avoid ambiguity. Then, you can override which target
356
            a particular name points to by overriding `[environments-preview].names`. For example,
357
            you could set this in `pants.toml`:
358

359
                [environments-preview.names]
360
                linux = "//:linux_env"
361
                macos = "//:macos_local_env"
362

363
            Then, for CI, override what the name `macos` points to by setting this in
364
            `pants.ci.toml`:
365

366
                [environments-preview.names.add]
367
                macos = "//:macos_ci_env"
368

369
            Locally, you can override `[environments-preview].names` like this by using a
370
            `.pants.rc` file, for example.
371
            """
372
        )
373
    )
374

375

376
@rule
12✔
377
async def get_target_for_environment_name(
12✔
378
    env_name: EnvironmentName, environments_subsystem: EnvironmentsSubsystem
379
) -> EnvironmentTarget:
380
    if env_name.val is None:
×
381
        return EnvironmentTarget(None, None)
×
382
    if env_name.val not in environments_subsystem.names:
×
383
        raise AssertionError(
×
384
            softwrap(
385
                f"""
386
                The name `{env_name.val}` is not defined. The name should have been normalized and
387
                validated in the resolve_environment_name() rule already. If you called
388
                `get_target_for_environment_name(EnvironmentName(name))`, refactor to
389
                `get_target_for_environment_name(**implicitly(EnvironmentNameRequest(name, ...))`.
390
                """
391
            )
392
        )
393
    _description_of_origin = "the option [environments-preview].names"
×
394
    address = await resolve_address(
×
395
        **implicitly(
396
            {
397
                AddressInput.parse(
398
                    environments_subsystem.names[env_name.val],
399
                    description_of_origin=_description_of_origin,
400
                ): AddressInput
401
            }
402
        )
403
    )
404
    wrapped_target = await resolve_target_for_bootstrapping(
×
405
        WrappedTargetRequest(address, description_of_origin=_description_of_origin), **implicitly()
406
    )
407
    tgt = wrapped_target.val
×
408
    if (
×
409
        not tgt.has_field(CompatiblePlatformsField)
410
        and not tgt.has_field(DockerImageField)
411
        and not tgt.has_field(RemotePlatformField)
412
    ):
413
        raise ValueError(
×
414
            softwrap(
415
                f"""
416
                Expected to use the address to a `local_environment`, `docker_environment`,
417
                `remote_environment`, or `experimental_workspace_environment` target in the option `[environments-preview].names`,
418
                but the name `{env_name.val}` was set to the target {address.spec} with the target type
419
                `{tgt.alias}`.
420
                """
421
            )
422
        )
423
    return EnvironmentTarget(env_name.val, tgt)
×
424

425

426
async def _apply_fallback_environment(env_tgt: Target, error_msg: str) -> EnvironmentName:
12✔
427
    fallback_field = env_tgt[FallbackEnvironmentField]
1✔
428
    if fallback_field.value is None:
1✔
429
        raise NoFallbackEnvironmentError(error_msg)
1✔
430
    return await resolve_environment_name(
1✔
431
        EnvironmentNameRequest(
432
            fallback_field.value,
433
            description_of_origin=(
434
                f"the `{fallback_field.alias}` field of the target {env_tgt.address}"
435
            ),
436
        ),
437
        **implicitly(),
438
    )
439

440

441
@rule
12✔
442
async def resolve_environment_name(
12✔
443
    request: EnvironmentNameRequest,
444
    environments_subsystem: EnvironmentsSubsystem,
445
    global_options: GlobalOptions,
446
) -> EnvironmentName:
447
    if request.raw_value == LOCAL_ENVIRONMENT_MATCHER:
1✔
448
        local_env_name = await determine_local_environment(**implicitly())
×
449
        return local_env_name.val
×
450
    if request.raw_value == LOCAL_WORKSPACE_ENVIRONMENT_MATCHER:
1✔
451
        local_workspace_env_name = await determine_local_workspace_environment(**implicitly())
×
452
        return local_workspace_env_name.val
×
453
    if request.raw_value not in environments_subsystem.names:
1✔
454
        raise UnrecognizedEnvironmentError(
×
455
            softwrap(
456
                f"""
457
                Unrecognized environment name `{request.raw_value}` from
458
                {request.description_of_origin}.
459

460
                The value must either be `{LOCAL_ENVIRONMENT_MATCHER}` or a name from the option
461
                `[environments-preview].names`: {sorted(environments_subsystem.names.keys())}
462
                """
463
            )
464
        )
465

466
    # Get the target so that we can apply the environment_fallback field, if relevant.
467
    env_tgt = await get_target_for_environment_name(
1✔
468
        EnvironmentName(request.raw_value), **implicitly()
469
    )
470
    if env_tgt.val is None:
1✔
471
        raise AssertionError(f"EnvironmentTarget.val is None for the name `{request.raw_value}`")
×
472

473
    if (
1✔
474
        env_tgt.val.has_field(RemoteFallbackEnvironmentField)
475
        and not global_options.remote_execution
476
    ):
477
        return await _apply_fallback_environment(
×
478
            env_tgt.val,
479
            error_msg=softwrap(
480
                f"""
481
                The global option `--remote-execution` is set to false, but the remote
482
                environment `{request.raw_value}` is used in {request.description_of_origin}.
483

484
                Either enable the option `--remote-execution`, or set the field
485
                `{FallbackEnvironmentField.alias}` for the target {env_tgt.val.address}.
486
                """
487
            ),
488
        )
489

490
    localhost_platform = Platform.create_for_localhost().value
1✔
491

492
    if env_tgt.val.has_field(DockerFallbackEnvironmentField):
1✔
493
        if not global_options.docker_execution:
1✔
494
            return await _apply_fallback_environment(
1✔
495
                env_tgt.val,
496
                error_msg=softwrap(
497
                    f"""
498
                    The global option `--docker-execution` is set to false, but the Docker
499
                    environment `{request.raw_value}` is used in {request.description_of_origin}.
500

501
                    Either enable the option `--docker-execution`, or set the field
502
                    `{FallbackEnvironmentField.alias}` for the target {env_tgt.val.address}.
503
                    """
504
                ),
505
            )
506

507
        if (
1✔
508
            localhost_platform in (Platform.linux_x86_64.value, Platform.linux_arm64.value)
509
            and localhost_platform != env_tgt.val[DockerPlatformField].normalized_value.value
510
        ):
511
            return await _apply_fallback_environment(
1✔
512
                env_tgt.val,
513
                error_msg=softwrap(
514
                    f"""
515
                    The Docker environment `{request.raw_value}` is specified in
516
                    {request.description_of_origin}, but it cannot be used because the local host has
517
                    the platform `{localhost_platform}` and the Docker environment has the platform
518
                    {env_tgt.val[DockerPlatformField].normalized_value}.
519

520
                    Consider setting the field `{FallbackEnvironmentField.alias}` for the target
521
                    {env_tgt.val.address}, such as to a `docker_environment` target that sets
522
                    `{DockerPlatformField.alias}` to `{localhost_platform}`. Alternatively, consider
523
                    not explicitly setting the field `{DockerPlatformField.alias}` for the target
524
                    {env_tgt.val.address} because the default behavior is to use the CPU architecture
525
                    of the current host for the platform (although this requires that the docker image
526
                    supports that CPU architecture).
527
                    """
528
                ),
529
            )
530

531
    if (
1✔
532
        env_tgt.val.has_field(LocalFallbackEnvironmentField)
533
        and localhost_platform not in env_tgt.val[CompatiblePlatformsField].value
534
    ):
535
        return await _apply_fallback_environment(
1✔
536
            env_tgt.val,
537
            error_msg=softwrap(
538
                f"""
539
                The local environment `{request.raw_value}` was specified in
540
                {request.description_of_origin}, but it is not compatible with the current
541
                machine's platform: {localhost_platform}. The environment only works with the
542
                platforms: {env_tgt.val[CompatiblePlatformsField].value}
543

544
                Consider setting the field `{FallbackEnvironmentField.alias}` for the target
545
                {env_tgt.val.address}, such as to a `docker_environment` or `remote_environment`
546
                target. You can also set that field to another `local_environment` target, such as
547
                one that is compatible with the current platform {localhost_platform}.
548
                """
549
            ),
550
        )
551

552
    return EnvironmentName(request.raw_value)
1✔
553

554

555
@rule
12✔
556
async def resolve_single_environment_name(
12✔
557
    request: SingleEnvironmentNameRequest,
558
) -> EnvironmentName:
559
    environment_names = await concurrently(
×
560
        resolve_environment_name(
561
            EnvironmentNameRequest(name, request.description_of_origin), **implicitly()
562
        )
563
        for name in request.raw_values
564
    )
565

566
    unique_environments = sorted({name.val or "<None>" for name in environment_names})
×
567
    if len(unique_environments) != 1:
×
568
        raise AssertionError(
×
569
            f"Needed 1 unique environment, but {request.description_of_origin} contained "
570
            f"{len(unique_environments)}:\n\n"
571
            f"{bullet_list(unique_environments)}"
572
        )
573
    return environment_names[0]
×
574

575

576
@rule
12✔
577
async def determine_all_environments(
12✔
578
    environments_subsystem: EnvironmentsSubsystem,
579
) -> AllEnvironmentTargets:
580
    resolved_tgts = await concurrently(
×
581
        get_target_for_environment_name(EnvironmentName(name), **implicitly())
582
        for name in environments_subsystem.names
583
    )
584
    return AllEnvironmentTargets(
×
585
        (name, resolved_tgt.val)
586
        for name, resolved_tgt in zip(environments_subsystem.names.keys(), resolved_tgts)
587
        if resolved_tgt.val is not None
588
    )
589

590

591
async def _maybe_add_docker_image_id(image_name: str, platform: Platform, address: Address) -> str:
12✔
592
    # If the image name appears to be just an image ID, just return it as-is.
593
    if image_name.startswith("sha256:"):
1✔
594
        return image_name
×
595

596
    # If the image name contains an appended image ID, then use it.
597
    if "@" in image_name:
1✔
598
        image_name_part, _, maybe_image_id = image_name.rpartition("@")
×
599
        if not maybe_image_id.startswith("sha256:"):
×
600
            raise ValueError(
×
601
                f"The Docker image `{image_name}` from the field {DockerImageField.alias} "
602
                f"for the target {address} contains what appears to be an image ID component, "
603
                "but does not appear to be the expected SHA-256 hash for an image."
604
            )
605
        return image_name
×
606

607
    # Otherwise, resolve the image name to an image ID and append the applicable component to the image name.
608
    resolve_result = await docker_resolve_image(
1✔
609
        DockerResolveImageRequest(
610
            image_name=image_name,
611
            platform=platform.name,
612
        )
613
    )
614

615
    # TODO(17104): Consider appending the correct image ID to the existing image name so error messages about the
616
    # image have context for the user. Note: The image ID used for a "repo digest" (tag and image ID) is not
617
    # the same as just the image's ID.
618
    return resolve_result.image_id
1✔
619

620

621
@rule
12✔
622
async def extract_process_config_from_environment(
12✔
623
    tgt: EnvironmentTarget,
624
    platform: Platform,
625
    global_options: GlobalOptions,
626
    keep_sandboxes: KeepSandboxes,
627
    environments_subsystem: EnvironmentsSubsystem,
628
) -> ProcessExecutionEnvironment:
629
    docker_image = None
1✔
630
    remote_execution = False
1✔
631
    raw_remote_execution_extra_platform_properties: tuple[str, ...] = ()
1✔
632
    execute_in_workspace = False
1✔
633

634
    if environments_subsystem.remote_execution_used_globally(global_options):
1✔
635
        remote_execution = True
1✔
636
        raw_remote_execution_extra_platform_properties = (
1✔
637
            global_options.remote_execution_extra_platform_properties
638
        )
639

640
    elif tgt.val is not None:
1✔
641
        docker_image = (
1✔
642
            tgt.val[DockerImageField].value if tgt.val.has_field(DockerImageField) else None
643
        )
644

645
        # If a docker image name is provided, convert to an image ID so caching works properly.
646
        # TODO(17104): Append image ID instead to the image name.
647
        if docker_image is not None:
1✔
648
            docker_image = await _maybe_add_docker_image_id(docker_image, platform, tgt.val.address)
1✔
649

650
        remote_execution = tgt.val.has_field(RemotePlatformField)
1✔
651
        if remote_execution:
1✔
652
            raw_remote_execution_extra_platform_properties = tgt.val[
1✔
653
                RemoteExtraPlatformPropertiesField
654
            ].value
655
            if global_options.remote_execution_extra_platform_properties:
1✔
656
                logger.warning(
1✔
657
                    softwrap(
658
                        f"""\
659
                        The option `[GLOBAL].remote_execution_extra_platform_properties` is set, but
660
                        it is ignored because you are using the environments target mechanism.
661
                        Instead, delete that option and set the field
662
                        `{RemoteExtraPlatformPropertiesField}.alias` on
663
                        `{RemoteEnvironmentTarget.alias}` targets.
664
                        """
665
                    )
666
                )
667

668
        execute_in_workspace = tgt.val.has_field(LocalWorkspaceCompatiblePlatformsField)
1✔
669

670
    return ProcessExecutionEnvironment(
1✔
671
        environment_name=tgt.name,
672
        platform=platform.value,
673
        docker_image=docker_image,
674
        remote_execution=remote_execution,
675
        remote_execution_extra_platform_properties=[
676
            tuple(pair.split("=", maxsplit=1))  # type: ignore[misc]
677
            for pair in raw_remote_execution_extra_platform_properties
678
        ],
679
        execute_in_workspace=execute_in_workspace,
680
        keep_sandboxes=keep_sandboxes.value,
681
    )
682

683

684
class _EnvironmentSensitiveOptionFieldMixin:
12✔
685
    subsystem: ClassVar[type[Subsystem.EnvironmentAware]]
12✔
686
    option_name: ClassVar[str]
12✔
687

688

689
class ShellStringSequenceField(StringSequenceField):
12✔
690
    @classmethod
12✔
691
    def compute_value(
12✔
692
        cls, raw_value: Iterable[str] | None, address: Address
693
    ) -> tuple[str, ...] | None:
694
        """Computes a flattened shlexed arg list from an iterable of strings."""
695
        if not raw_value:
×
696
            return ()
×
697

698
        return tuple(arg for raw_arg in raw_value for arg in shlex.split(raw_arg))
×
699

700

701
# Maps between non-list option value types and corresponding fields
702
_SIMPLE_OPTIONS: dict[type | Callable[[str], Any], type[Field]] = {
12✔
703
    str: StringField,
704
}
705

706
# Maps between the member types for list options. Each element is the
707
# field type, and the `value` type for the field.
708
_LIST_OPTIONS: dict[type | Callable[[str], Any], type[Field]] = {
12✔
709
    str: StringSequenceField,
710
    custom_types.shell_str: ShellStringSequenceField,
711
}
712

713

714
@memoized
12✔
715
def add_option_fields_for(env_aware: type[Subsystem.EnvironmentAware]) -> Iterable[UnionRule]:
12✔
716
    """Register environment fields for the options declared in `env_aware`
717

718
    This is called by `env_aware.subsystem.rules()`, which is called whenever a rule depends on
719
    `env_aware`. It will register the relevant fields under the `local_environment` and
720
    `docker_environment` targets. Note that it must be `memoized`, such that repeated calls result
721
    in exactly the same rules being registered.
722
    """
723

724
    field_rules: set[UnionRule] = set()
12✔
725

726
    for option in collect_options_info(env_aware):
12✔
727
        field_rules.update(_add_option_field_for(env_aware, option))
12✔
728

729
    return field_rules
12✔
730

731

732
def option_field_name_for(flag_names: Sequence[str]) -> str:
12✔
733
    return flag_names[0][2:].replace("-", "_")
12✔
734

735

736
def _add_option_field_for(
12✔
737
    env_aware_t: type[Subsystem.EnvironmentAware],
738
    option: OptionInfo,
739
) -> Iterable[UnionRule]:
740
    option_type: type = option.kwargs["type"]
12✔
741
    scope = env_aware_t.subsystem.options_scope
12✔
742

743
    snake_name = option_field_name_for(option.args)
12✔
744

745
    # Note that there is not presently good support for enum options. `str`-backed enums should
746
    # be easy enough to add though...
747

748
    if option_type != list:
12✔
749
        try:
12✔
750
            field_type = _SIMPLE_OPTIONS[option_type]
12✔
751
        except KeyError:
×
752
            raise AssertionError(
×
753
                f"The option `[{scope}].{snake_name}` has a value type that does not yet have a "
754
                "mapping in `environments.py`. To fix, map the value type in `_SIMPLE_OPTIONS` "
755
                "to a `Field` subtype that supports your option's value type."
756
            )
757
    else:
758
        member_type = option.kwargs["member_type"]
12✔
759
        try:
12✔
760
            field_type = _LIST_OPTIONS[member_type]
12✔
761
        except KeyError:
×
762
            raise AssertionError(
×
763
                f"The option `[{scope}].{snake_name}` has a member value type that does yet have "
764
                "a mapping in `environments.py`. To fix, map the member value type in "
765
                "`_LIST_OPTIONS` to a `SequenceField` subtype that supports your option's member "
766
                "value type."
767
            )
768

769
    class_attrs = {
12✔
770
        "alias": f"{scope}_{snake_name}".replace("-", "_"),
771
        "required": False,
772
        "help": (
773
            f"Overrides the default value from the option `[{scope}].{snake_name}` when this "
774
            "environment target is active."
775
        ),
776
        "subsystem": env_aware_t,
777
        "option_name": option.args[0],
778
    }
779

780
    # For sequence fields, use special handling to distinguish "not specified" from "explicitly empty"
781
    if issubclass(field_type, SequenceField):
12✔
782
        class_attrs["none_is_valid_value"] = True
12✔
783

784
        def compute_value(cls, raw_value: Any | None, address: Address) -> Any | None:
12✔
785
            # When field is not specified in BUILD file, return None to indicate inheritance
786
            if raw_value is NO_VALUE:
×
787
                return None
×
788
            # For other values, use normal field processing
789
            return field_type.compute_value(raw_value, address)
×
790

791
        class_attrs["compute_value"] = classmethod(compute_value)
12✔
792

793
    # Create the OptionField class dynamically
794
    OptionField = cast(
12✔
795
        "type[Field]",
796
        type("OptionField", (field_type, _EnvironmentSensitiveOptionFieldMixin), class_attrs),
797
    )
798
    setattr(OptionField, "__qualname__", f"{option_type.__qualname__}.{OptionField.__name__}")
12✔
799

800
    return [
12✔
801
        LocalEnvironmentTarget.register_plugin_field(OptionField),
802
        LocalWorkspaceEnvironmentTarget.register_plugin_field(OptionField),
803
        DockerEnvironmentTarget.register_plugin_field(OptionField),
804
        RemoteEnvironmentTarget.register_plugin_field(OptionField),
805
    ]
806

807

808
def resolve_environment_sensitive_option(name: str, subsystem: Subsystem.EnvironmentAware):
12✔
809
    """Return the value from the environment field corresponding to the scope and name provided.
810

811
    If not defined, return `None`.
812
    """
813

814
    env_tgt = subsystem.env_tgt
1✔
815

816
    if env_tgt.val is None:
1✔
817
        return None
×
818

819
    options = _options(env_tgt)
1✔
820

821
    maybe = options.get((type(subsystem), name))
1✔
822
    if maybe is None or maybe.value is None:
1✔
823
        return None
1✔
824

825
    return maybe.value
1✔
826

827

828
@memoized
12✔
829
def _options(
12✔
830
    env_tgt: EnvironmentTarget,
831
) -> dict[tuple[type[Subsystem.EnvironmentAware], str], Field]:
832
    """Index the environment-specific `fields` on an environment target by subsystem and name."""
833

834
    options: dict[tuple[type[Subsystem.EnvironmentAware], str], Field] = {}
1✔
835

836
    if env_tgt.val is None:
1✔
837
        return options
×
838

839
    for _, field in env_tgt.val.field_values.items():
1✔
840
        if isinstance(field, _EnvironmentSensitiveOptionFieldMixin):
1✔
841
            options[(field.subsystem, field.option_name)] = field
1✔
842

843
    return options
1✔
844

845

846
def rules():
12✔
847
    return (
12✔
848
        *collect_rules(),
849
        UnionRule(FieldDefaultFactoryRequest, DockerPlatformFieldDefaultFactoryRequest),
850
        QueryRule(ChosenLocalEnvironmentName, []),
851
    )
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

© 2025 Coveralls, Inc