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

pantsbuild / pants / 20147226056

11 Dec 2025 08:58PM UTC coverage: 78.827% (-1.5%) from 80.293%
20147226056

push

github

web-flow
Forwarded the `style` and `complete-platform` args from pants.toml to PEX (#22910)

## Context

After Apple switched to the `arm64` architecture, some package
publishers stopped releasing `x86_64` variants of their packages for
`darwin`. As a result, generating a universal lockfile now fails because
no single package version is compatible with both `x86_64` and `arm64`
on `darwin`.

The solution is to use the `--style` and `--complete-platform` flags
with PEX. For example:
```
pex3 lock create \
    --style strict \
    --complete-platform 3rdparty/platforms/manylinux_2_28_aarch64.json \
    --complete-platform 3rdparty/platforms/macosx_26_0_arm64.json \
    -r 3rdparty/python/requirements_pyarrow.txt \
    -o python-pyarrow.lock
```

See the Slack discussion here:
https://pantsbuild.slack.com/archives/C046T6T9U/p1760098582461759

## Reproduction

* `BUILD`
```
python_requirement(
    name="awswrangler",
    requirements=["awswrangler==3.12.1"],
    resolve="awswrangler",
)
```
* Run `pants generate-lockfiles --resolve=awswrangler` on macOS with an
`arm64` CPU
```
pip: ERROR: Cannot install awswrangler==3.12.1 because these package versions have conflicting dependencies.
pip: ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
pip:  
pip:  The conflict is caused by:
pip:      awswrangler 3.12.1 depends on pyarrow<18.0.0 and >=8.0.0; sys_platform == "darwin" and platform_machine == "x86_64"
pip:      awswrangler 3.12.1 depends on pyarrow<21.0.0 and >=18.0.0; sys_platform != "darwin" or platform_machine != "x86_64"
pip:  
pip:  Additionally, some packages in these conflicts have no matching distributions available for your environment:
pip:      pyarrow
pip:  
pip:  To fix this you could try to:
pip:  1. loosen the range of package versions you've specified
pip:  2. remove package versions to allow pip to attempt to solve the dependency conflict
```

## Implementation
... (continued)

77 of 100 new or added lines in 6 files covered. (77.0%)

868 existing lines in 42 files now uncovered.

74471 of 94474 relevant lines covered (78.83%)

3.18 hits per line

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

83.66
/src/python/pants/help/help_info_extracter.py
1
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
11✔
5

6
import ast
11✔
7
import dataclasses
11✔
8
import difflib
11✔
9
import importlib.metadata
11✔
10
import inspect
11✔
11
import itertools
11✔
12
import json
11✔
13
import re
11✔
14
from collections import defaultdict, namedtuple
11✔
15
from collections.abc import Callable, Iterator, Sequence
11✔
16
from dataclasses import dataclass
11✔
17
from enum import Enum
11✔
18
from functools import reduce
11✔
19
from itertools import chain
11✔
20
from pathlib import Path
11✔
21
from typing import Any, DefaultDict, TypeVar, Union, cast, get_type_hints
11✔
22

23
import pants.backend
11✔
24
from pants.base import deprecated
11✔
25
from pants.build_graph.build_configuration import BuildConfiguration
11✔
26
from pants.core.environments.rules import option_field_name_for
11✔
27
from pants.engine.goal import GoalSubsystem
11✔
28
from pants.engine.internals.parser import BuildFileSymbolInfo, BuildFileSymbolsInfo, Registrar
11✔
29
from pants.engine.rules import Rule, TaskRule
11✔
30
from pants.engine.target import Field, RegisteredTargetTypes, StringField, Target, TargetGenerator
11✔
31
from pants.engine.unions import UnionMembership, UnionRule, is_union
11✔
32
from pants.option.native_options import NativeOptionParser, parse_dest
11✔
33
from pants.option.option_types import OptionInfo
11✔
34
from pants.option.option_util import is_dict_option, is_list_option
11✔
35
from pants.option.options import Options
11✔
36
from pants.option.ranked_value import Rank, RankedValue
11✔
37
from pants.option.registrar import OptionRegistrar, OptionValueHistory
11✔
38
from pants.option.scope import GLOBAL_SCOPE, ScopeInfo
11✔
39
from pants.util.frozendict import LazyFrozenDict
11✔
40
from pants.util.strutil import first_paragraph, strval
11✔
41

42
T = TypeVar("T")
11✔
43

44
_ENV_SANITIZER_RE = re.compile(r"[.-]")
11✔
45

46

47
class HelpJSONEncoder(json.JSONEncoder):
11✔
48
    """Class for JSON-encoding help data (including option values).
49

50
    Note that JSON-encoded data is not intended to be decoded back. It exists purely for terminal
51
    and browser help display.
52
    """
53

54
    def default(self, o):
11✔
55
        if callable(o):
×
56
            return o.__name__
×
57
        if isinstance(o, type):
×
58
            return type.__name__
×
59
        if isinstance(o, Enum):
×
60
            return o.value
×
61
        return super().default(o)
×
62

63

64
def to_help_str(val) -> str:
11✔
65
    if isinstance(val, (list, dict)):
1✔
UNCOV
66
        return json.dumps(val, sort_keys=True, indent=2, cls=HelpJSONEncoder)
×
67
    if isinstance(val, Enum):
1✔
UNCOV
68
        return str(val.value)
×
69
    else:
70
        return str(val)
1✔
71

72

73
@dataclass(frozen=True)
11✔
74
class OptionHelpInfo:
11✔
75
    """A container for help information for a single option.
76

77
    display_args: Arg strings suitable for display in help text, including value examples
78
                  (e.g., [-f, --[no]-foo-bar, --baz=<metavar>].)
79
    comma_separated_display_args: Display args as a comma-delimited string, used in
80
                                  reference documentation.
81
    scoped_cmd_line_args: The explicitly scoped raw flag names allowed anywhere on the cmd line,
82
                          (e.g., [--scope-baz, --no-scope-baz, --scope-qux])
83
    unscoped_cmd_line_args: The unscoped raw flag names allowed on the cmd line in this option's
84
                            scope context (e.g., [--baz, --no-baz, --qux])
85
    env_var: The environment variable that set's the option.
86
    config_key: The config key for this option (in the section named for its scope).
87
    target_field_name: The name for the field that overrides this option in `local_environment`,
88
                       `remote_environment`, or `docker_environment`, if the option is environment-
89
                       aware, else `None`.
90
    typ: The type of the option.
91
    default: The value of this option if no flags are specified (derived from config and env vars).
92
    help: The help message registered for this option.
93
    deprecated_message: If deprecated: A message explaining that this option is deprecated at
94
                        removal_version.
95
    removal_version: If deprecated: The version at which this option is to be removed.
96
    removal_hint: If deprecated: The removal hint message registered for this option.
97
    choices: If this option has a constrained set of choices, a tuple of the stringified choices.
98
    fromfile: Supports reading the option value from a file when using `@filepath`.
99
    """
100

101
    display_args: tuple[str, ...]
11✔
102
    comma_separated_display_args: str
11✔
103
    scoped_cmd_line_args: tuple[str, ...]
11✔
104
    unscoped_cmd_line_args: tuple[str, ...]
11✔
105
    env_var: str
11✔
106
    config_key: str
11✔
107
    target_field_name: str | None
11✔
108
    typ: type
11✔
109
    default: Any
11✔
110
    help: str
11✔
111
    deprecation_active: bool
11✔
112
    deprecated_message: str | None
11✔
113
    removal_version: str | None
11✔
114
    removal_hint: str | None
11✔
115
    choices: tuple[str, ...] | None
11✔
116
    comma_separated_choices: str | None
11✔
117
    value_history: OptionValueHistory | None
11✔
118
    fromfile: bool
11✔
119

120

121
@dataclass(frozen=True)
11✔
122
class OptionScopeHelpInfo:
11✔
123
    """A container for help information for a scope of options.
124

125
    scope: The scope of the described options.
126
    provider: Which backend or plugin registered this scope.
127
    deprecated_scope: A deprecated scope name for this scope. A scope that has a deprecated scope
128
      will be represented by two objects - one of which will have scope==deprecated_scope.
129
    basic|advanced|deprecated: A list of OptionHelpInfo for the options in that group.
130
    """
131

132
    scope: str
11✔
133
    description: str
11✔
134
    provider: str
11✔
135
    is_goal: bool  # True iff the scope belongs to a GoalSubsystem.
11✔
136
    deprecated_scope: str | None
11✔
137
    basic: tuple[OptionHelpInfo, ...]
11✔
138
    advanced: tuple[OptionHelpInfo, ...]
11✔
139
    deprecated: tuple[OptionHelpInfo, ...]
11✔
140

141
    def is_deprecated_scope(self):
11✔
142
        """Returns True iff this scope is deprecated.
143

144
        We may choose not to show deprecated scopes when enumerating scopes, but still want to show
145
        help for individual deprecated scopes when explicitly requested.
146
        """
147
        return self.scope == self.deprecated_scope
×
148

149
    def collect_unscoped_flags(self) -> list[str]:
11✔
150
        flags: list[str] = []
×
151
        for options in (self.basic, self.advanced, self.deprecated):
×
152
            for ohi in options:
×
153
                flags.extend(ohi.unscoped_cmd_line_args)
×
154
        return flags
×
155

156
    def collect_scoped_flags(self) -> list[str]:
11✔
157
        flags: list[str] = []
×
158
        for options in (self.basic, self.advanced, self.deprecated):
×
159
            for ohi in options:
×
160
                flags.extend(ohi.scoped_cmd_line_args)
×
161
        return flags
×
162

163

164
@dataclass(frozen=True)
11✔
165
class GoalHelpInfo:
11✔
166
    """A container for help information for a goal."""
167

168
    name: str
11✔
169
    description: str
11✔
170
    provider: str
11✔
171
    is_implemented: bool  # True iff all unions required by the goal are implemented.
11✔
172
    consumed_scopes: tuple[str, ...]  # The scopes of subsystems consumed by this goal.
11✔
173

174

175
def pretty_print_type_hint(hint: Any) -> str:
11✔
176
    if getattr(hint, "__origin__", None) == Union:
1✔
177
        union_members = hint.__args__
1✔
178
        hint_str = " | ".join(pretty_print_type_hint(member) for member in union_members)
1✔
179
    # NB: Checking for GenericMeta is only for Python 3.6 because some `typing` classes like
180
    # `typing.Iterable` have its type, whereas Python 3.7+ removes it. Remove this check
181
    # once we drop support for Python 3.6.
182
    elif isinstance(hint, type) and not str(type(hint)) == "<class 'typing.GenericMeta'>":
1✔
183
        hint_str = hint.__name__
1✔
184
    else:
185
        hint_str = str(hint)
1✔
186
    return (
1✔
187
        hint_str.replace("collections.abc.", "").replace("typing.", "").replace("NoneType", "None")
188
    )
189

190

191
@dataclass(frozen=True)
11✔
192
class TargetFieldHelpInfo:
11✔
193
    """A container for help information for a field in a target type."""
194

195
    alias: str
11✔
196
    provider: str
11✔
197
    description: str
11✔
198
    type_hint: str
11✔
199
    required: bool
11✔
200
    default: str | None
11✔
201

202
    @classmethod
11✔
203
    def create(cls, field: type[Field], *, provider: str) -> TargetFieldHelpInfo:
11✔
204
        raw_value_type = get_type_hints(field.compute_value)["raw_value"]
1✔
205
        type_hint = pretty_print_type_hint(raw_value_type)
1✔
206

207
        # Check if the field only allows for certain choices.
208
        if issubclass(field, StringField) and field.valid_choices is not None:
1✔
209
            valid_choices = sorted(
×
210
                field.valid_choices
211
                if isinstance(field.valid_choices, tuple)
212
                else (choice.value for choice in field.valid_choices)
213
            )
214
            type_hint = " | ".join([*(repr(c) for c in valid_choices), "None"])
×
215

216
        if field.required:
1✔
217
            # We hackily remove `None` as a valid option for the field when it's required. This
218
            # greatly simplifies Field definitions because it means that they don't need to
219
            # override the type hints for `PrimitiveField.compute_value()` and
220
            # `AsyncField.sanitize_raw_value()` to indicate that `None` is an invalid type.
UNCOV
221
            type_hint = type_hint.replace(" | None", "")
×
222

223
        return cls(
1✔
224
            alias=field.alias,
225
            provider=provider,
226
            description=strval(field.help),
227
            type_hint=type_hint,
228
            required=field.required,
229
            default=(
230
                repr(field.default) if (not field.required and field.default is not None) else None
231
            ),
232
        )
233

234

235
@dataclass(frozen=True)
11✔
236
class TargetTypeHelpInfo:
11✔
237
    """A container for help information for a target type."""
238

239
    alias: str
11✔
240
    provider: str
11✔
241
    summary: str
11✔
242
    description: str
11✔
243
    fields: tuple[TargetFieldHelpInfo, ...]
11✔
244

245
    @classmethod
11✔
246
    def create(
11✔
247
        cls,
248
        target_type: type[Target],
249
        *,
250
        provider: str,
251
        union_membership: UnionMembership,
252
        get_field_type_provider: Callable[[type[Field]], str] | None,
253
    ) -> TargetTypeHelpInfo:
254
        fields = list(target_type.class_field_types(union_membership=union_membership))
1✔
255
        if issubclass(target_type, TargetGenerator):
1✔
256
            # NB: Even though the moved_fields will never be present on a constructed
257
            # TargetGenerator, they are legal arguments... and that is what most help consumers
258
            # are interested in.
259
            fields.extend(target_type.moved_fields)
×
260
        helpstr = strval(target_type.help)
1✔
261
        return cls(
1✔
262
            alias=target_type.alias,
263
            provider=provider,
264
            summary=first_paragraph(helpstr),
265
            description=helpstr,
266
            fields=tuple(
267
                TargetFieldHelpInfo.create(
268
                    field,
269
                    provider=(
270
                        "" if get_field_type_provider is None else get_field_type_provider(field)
271
                    ),
272
                )
273
                for field in fields
274
                if not field.alias.startswith("_") and field.removal_version is None
275
            ),
276
        )
277

278

279
def maybe_cleandoc(doc: str | None) -> str | None:
11✔
280
    return doc and inspect.cleandoc(doc)
1✔
281

282

283
@dataclass(frozen=True)
11✔
284
class RuleInfo:
11✔
285
    """A container for help information for a rule.
286

287
    The `description` is the `desc` provided to the `@rule` decorator, and `documentation` is the
288
    rule's doc string.
289
    """
290

291
    name: str
11✔
292
    description: str | None
11✔
293
    documentation: str | None
11✔
294
    provider: str
11✔
295
    output_type: str
11✔
296
    input_types: tuple[str, ...]
11✔
297
    awaitables: tuple[str, ...]
11✔
298

299
    @classmethod
11✔
300
    def create(cls, rule: TaskRule, provider: str) -> RuleInfo:
11✔
301
        return cls(
1✔
302
            name=rule.canonical_name,
303
            description=rule.desc,
304
            documentation=maybe_cleandoc(rule.func.__doc__),
305
            provider=provider,
306
            input_types=tuple(typ.__name__ for typ in rule.parameters.values()),
307
            awaitables=tuple(str(constraints) for constraints in rule.awaitables),
308
            output_type=rule.output_type.__name__,
309
        )
310

311

312
@dataclass(frozen=True)
11✔
313
class PluginAPITypeInfo:
11✔
314
    """A container for help information for a plugin API type.
315

316
    Plugin API types are used as input parameters and output results for rules.
317
    """
318

319
    name: str
11✔
320
    module: str
11✔
321
    documentation: str | None
11✔
322
    provider: tuple[str, ...]
11✔
323
    is_union: bool
11✔
324
    union_type: str | None
11✔
325
    union_members: tuple[str, ...]
11✔
326
    dependencies: tuple[str, ...]
11✔
327
    dependents: tuple[str, ...]
11✔
328
    returned_by_rules: tuple[str, ...]
11✔
329
    consumed_by_rules: tuple[str, ...]
11✔
330
    used_in_rules: tuple[str, ...]
11✔
331

332
    @property
11✔
333
    def fully_qualified_name(self) -> str:
11✔
334
        return f"{self.module}.{self.name}"
×
335

336
    @staticmethod
11✔
337
    def fully_qualified_name_from_type(t: type):
11✔
338
        return f"{t.__module__}.{t.__qualname__}"
2✔
339

340
    @classmethod
11✔
341
    def create(
11✔
342
        cls, api_type: type, rules: Sequence[Rule | UnionRule], **kwargs
343
    ) -> PluginAPITypeInfo:
UNCOV
344
        union_type: str | None = None
×
UNCOV
345
        for rule in filter(cls._member_type_for(api_type), rules):
×
346
            # All filtered rules out of `_member_type_for` will be `UnionRule`s.
347
            union_type = cast(UnionRule, rule).union_base.__name__
×
348
            break
×
349

UNCOV
350
        task_rules = [rule for rule in rules if isinstance(rule, TaskRule)]
×
351

UNCOV
352
        return cls(
×
353
            name=api_type.__qualname__,
354
            module=api_type.__module__,
355
            documentation=maybe_cleandoc(api_type.__doc__),
356
            is_union=is_union(api_type),
357
            union_type=union_type,
358
            consumed_by_rules=cls._all_rules(cls._rule_consumes(api_type), task_rules),
359
            returned_by_rules=cls._all_rules(cls._rule_returns(api_type), task_rules),
360
            used_in_rules=cls._all_rules(cls._rule_uses(api_type), task_rules),
361
            **kwargs,
362
        )
363

364
    @staticmethod
11✔
365
    def _all_rules(
11✔
366
        satisfies: Callable[[TaskRule], bool], rules: Sequence[TaskRule]
367
    ) -> tuple[str, ...]:
UNCOV
368
        return tuple(sorted(rule.canonical_name for rule in filter(satisfies, rules)))
×
369

370
    @staticmethod
11✔
371
    def _rule_consumes(api_type: type) -> Callable[[TaskRule], bool]:
11✔
UNCOV
372
        def satisfies(rule: TaskRule) -> bool:
×
UNCOV
373
            return api_type in rule.parameters.values()
×
374

UNCOV
375
        return satisfies
×
376

377
    @staticmethod
11✔
378
    def _rule_returns(api_type: type) -> Callable[[TaskRule], bool]:
11✔
UNCOV
379
        def satisfies(rule: TaskRule) -> bool:
×
UNCOV
380
            return rule.output_type is api_type
×
381

UNCOV
382
        return satisfies
×
383

384
    @staticmethod
11✔
385
    def _rule_uses(api_type: type) -> Callable[[TaskRule], bool]:
11✔
UNCOV
386
        def satisfies(rule: TaskRule) -> bool:
×
UNCOV
387
            return any(
×
388
                api_type in (*constraint.input_types, constraint.output_type)
389
                for constraint in rule.awaitables
390
            )
391

UNCOV
392
        return satisfies
×
393

394
    @staticmethod
11✔
395
    def _member_type_for(api_type: type) -> Callable[[Rule | UnionRule], bool]:
11✔
UNCOV
396
        def satisfies(rule: Rule | UnionRule) -> bool:
×
UNCOV
397
            return isinstance(rule, UnionRule) and rule.union_member is api_type
×
398

UNCOV
399
        return satisfies
×
400

401
    def merged_with(self, that: PluginAPITypeInfo) -> PluginAPITypeInfo:
11✔
402
        def merge_tuples(l, r):
×
403
            return tuple(sorted({*l, *r}))
×
404

405
        return PluginAPITypeInfo(
×
406
            self.name,
407
            self.module,
408
            self.documentation,
409
            merge_tuples(self.provider, that.provider),
410
            self.is_union,
411
            self.union_type,
412
            merge_tuples(self.union_members, that.union_members),
413
            merge_tuples(self.dependencies, that.dependencies),
414
            merge_tuples(self.dependents, that.dependents),
415
            merge_tuples(self.returned_by_rules, that.returned_by_rules),
416
            merge_tuples(self.consumed_by_rules, that.consumed_by_rules),
417
            merge_tuples(self.used_in_rules, that.used_in_rules),
418
        )
419

420

421
@dataclass(frozen=True)
11✔
422
class BackendHelpInfo:
11✔
423
    name: str
11✔
424
    description: str
11✔
425
    enabled: bool
11✔
426
    provider: str
11✔
427

428

429
@dataclass(frozen=True)
11✔
430
class BuildFileSymbolHelpInfo:
11✔
431
    name: str
11✔
432
    is_target: bool
11✔
433
    signature: str | None
11✔
434
    documentation: str | None
11✔
435

436

437
@dataclass(frozen=True)
11✔
438
class AllHelpInfo:
11✔
439
    """All available help info."""
440

441
    scope_to_help_info: LazyFrozenDict[str, OptionScopeHelpInfo]
11✔
442
    name_to_goal_info: LazyFrozenDict[str, GoalHelpInfo]
11✔
443
    name_to_target_type_info: LazyFrozenDict[str, TargetTypeHelpInfo]
11✔
444
    name_to_rule_info: LazyFrozenDict[str, RuleInfo]
11✔
445
    name_to_api_type_info: LazyFrozenDict[str, PluginAPITypeInfo]
11✔
446
    name_to_backend_help_info: LazyFrozenDict[str, BackendHelpInfo]
11✔
447
    name_to_build_file_info: LazyFrozenDict[str, BuildFileSymbolHelpInfo]
11✔
448
    env_var_to_help_info: LazyFrozenDict[str, OptionHelpInfo]
11✔
449

450
    def non_deprecated_option_scope_help_infos(self):
11✔
451
        for oshi in self.scope_to_help_info.values():
×
452
            if not oshi.is_deprecated_scope():
×
453
                yield oshi
×
454

455
    def asdict(self) -> dict[str, Any]:
11✔
UNCOV
456
        return {
×
457
            field: {thing: dataclasses.asdict(info) for thing, info in value.items()}
458
            for field, value in dataclasses.asdict(self).items()
459
        }
460

461

462
ConsumedScopesMapper = Callable[[str], tuple[str, ...]]
11✔
463

464

465
class HelpInfoExtracter:
11✔
466
    """Extracts information useful for displaying help from option registration args."""
467

468
    @classmethod
11✔
469
    def get_all_help_info(
11✔
470
        cls,
471
        options: Options,
472
        union_membership: UnionMembership,
473
        consumed_scopes_mapper: ConsumedScopesMapper,
474
        registered_target_types: RegisteredTargetTypes,
475
        build_symbols: BuildFileSymbolsInfo,
476
        build_configuration: BuildConfiguration | None = None,
477
    ) -> AllHelpInfo:
478
        def option_scope_help_info_loader_for(
2✔
479
            scope_info: ScopeInfo,
480
        ) -> Callable[[], OptionScopeHelpInfo]:
481
            def load() -> OptionScopeHelpInfo:
2✔
482
                subsystem_cls = scope_info.subsystem_cls
2✔
483
                if not scope_info.description:
2✔
484
                    cls_name = (
×
485
                        f"{subsystem_cls.__module__}.{subsystem_cls.__qualname__}"
486
                        if subsystem_cls
487
                        else ""
488
                    )
489
                    raise ValueError(
×
490
                        f"Subsystem {cls_name} with scope `{scope_info.scope}` has no description. "
491
                        f"Add a class property `help`."
492
                    )
493
                provider = ""
2✔
494
                if subsystem_cls is not None and build_configuration is not None:
2✔
495
                    provider = cls.get_provider(
2✔
496
                        build_configuration.subsystem_to_providers.get(subsystem_cls),
497
                        subsystem_cls.__module__,
498
                    )
499
                return HelpInfoExtracter(scope_info.scope).get_option_scope_help_info(
2✔
500
                    scope_info.description,
501
                    options.get_registrar(scope_info.scope),
502
                    options.native_parser.with_derivation(),
503
                    # `filter` should be treated as a subsystem for `help`, even though it still
504
                    # works as a goal for backwards compatibility.
505
                    scope_info.is_goal if scope_info.scope != "filter" else False,
506
                    provider,
507
                    scope_info.deprecated_scope,
508
                )
509

510
            return load
2✔
511

512
        def goal_help_info_loader_for(scope_info: ScopeInfo) -> Callable[[], GoalHelpInfo]:
2✔
513
            def load() -> GoalHelpInfo:
2✔
UNCOV
514
                subsystem_cls = scope_info.subsystem_cls
×
UNCOV
515
                assert subsystem_cls is not None
×
UNCOV
516
                if build_configuration is not None:
×
UNCOV
517
                    provider = cls.get_provider(
×
518
                        build_configuration.subsystem_to_providers.get(subsystem_cls),
519
                        subsystem_cls.__module__,
520
                    )
UNCOV
521
                goal_subsystem_cls = cast(type[GoalSubsystem], subsystem_cls)
×
UNCOV
522
                return GoalHelpInfo(
×
523
                    goal_subsystem_cls.name,
524
                    scope_info.description,
525
                    provider,
526
                    goal_subsystem_cls.activated(union_membership),
527
                    consumed_scopes_mapper(scope_info.scope),
528
                )
529

530
            return load
2✔
531

532
        def target_type_info_for(target_type: type[Target]) -> Callable[[], TargetTypeHelpInfo]:
2✔
533
            def load() -> TargetTypeHelpInfo:
2✔
534
                return TargetTypeHelpInfo.create(
1✔
535
                    target_type,
536
                    union_membership=union_membership,
537
                    provider=cls.get_provider(
538
                        build_configuration
539
                        and build_configuration.target_type_to_providers.get(target_type)
540
                        or None,
541
                        target_type.__module__,
542
                    ),
543
                    get_field_type_provider=lambda field_type: cls.get_provider(
544
                        (
545
                            build_configuration.union_rule_to_providers.get(
546
                                UnionRule(target_type.PluginField, field_type)
547
                            )
548
                            if build_configuration is not None
549
                            else None
550
                        ),
551
                        field_type.__module__,
552
                    ),
553
                )
554

555
            return load
2✔
556

557
        def lazily(value: T) -> Callable[[], T]:
2✔
558
            return lambda: value
2✔
559

560
        known_scope_infos = sorted(options.known_scope_to_info.values(), key=lambda x: x.scope)
2✔
561
        scope_to_help_info = LazyFrozenDict(
2✔
562
            {
563
                scope_info.scope: option_scope_help_info_loader_for(scope_info)
564
                for scope_info in known_scope_infos
565
                if not scope_info.scope.startswith("_")
566
            }
567
        )
568

569
        env_var_to_help_info = LazyFrozenDict(
2✔
570
            {
571
                ohi.env_var: lazily(ohi)
572
                for oshi in scope_to_help_info.values()
573
                for ohi in chain(oshi.basic, oshi.advanced, oshi.deprecated)
574
            }
575
        )
576

577
        name_to_goal_info = LazyFrozenDict(
2✔
578
            {
579
                scope_info.scope: goal_help_info_loader_for(scope_info)
580
                for scope_info in known_scope_infos
581
                if (
582
                    scope_info.is_goal
583
                    and not scope_info.scope.startswith("_")
584
                    # `filter` should be treated as a subsystem for `help`, even though it still
585
                    # works as a goal for backwards compatibility.
586
                    and scope_info.scope != "filter"
587
                )
588
            }
589
        )
590

591
        name_to_target_type_info = LazyFrozenDict(
2✔
592
            {
593
                alias: target_type_info_for(target_type)
594
                for alias, target_type in registered_target_types.aliases_to_types.items()
595
                if (target_type.removal_version is None and alias != target_type.deprecated_alias)
596
            }
597
        )
598

599
        return AllHelpInfo(
2✔
600
            scope_to_help_info=scope_to_help_info,
601
            name_to_goal_info=name_to_goal_info,
602
            name_to_target_type_info=name_to_target_type_info,
603
            name_to_rule_info=cls.get_rule_infos(build_configuration),
604
            name_to_api_type_info=cls.get_api_type_infos(build_configuration, union_membership),
605
            name_to_backend_help_info=cls.get_backend_help_info(options),
606
            name_to_build_file_info=cls.get_build_file_info(build_symbols),
607
            env_var_to_help_info=env_var_to_help_info,
608
        )
609

610
    @staticmethod
11✔
611
    def compute_default(**kwargs) -> Any:
11✔
612
        """Compute the default val for help display for an option registered with these kwargs."""
613
        # If the kwargs already determine a string representation of the default for use in help
614
        # messages, use that.
615
        default_help_repr = kwargs.get("default_help_repr")
2✔
616
        if default_help_repr is not None:
2✔
617
            return str(default_help_repr)  # Should already be a string, but might as well be safe.
2✔
618

619
        default = kwargs.get("default")
2✔
620
        if default is not None:
2✔
621
            return default
2✔
622
        if is_list_option(kwargs):
2✔
UNCOV
623
            return []
×
624
        elif is_dict_option(kwargs):
2✔
UNCOV
625
            return {}
×
626
        return None
2✔
627

628
    @staticmethod
11✔
629
    def stringify_type(t: type) -> str:
11✔
630
        if t == dict:
2✔
631
            return "{'key1': val1, 'key2': val2, ...}"
2✔
632
        return f"<{t.__name__}>"
2✔
633

634
    @staticmethod
11✔
635
    def compute_metavar(kwargs):
11✔
636
        """Compute the metavar to display in help for an option registered with these kwargs."""
637

638
        stringify = HelpInfoExtracter.stringify_type
2✔
639

640
        metavar = kwargs.get("metavar")
2✔
641
        if not metavar:
2✔
642
            if is_list_option(kwargs):
2✔
643
                member_typ = kwargs.get("member_type", str)
2✔
644
                metavar = stringify(member_typ)
2✔
645
                # In a cmd-line list literal, string members must be quoted.
646
                if member_typ == str:
2✔
647
                    metavar = f"'{metavar}'"
2✔
648
            elif is_dict_option(kwargs):
2✔
649
                metavar = f'"{stringify(dict)}"'
2✔
650
            else:
651
                metavar = stringify(kwargs.get("type", str))
2✔
652
        if is_list_option(kwargs):
2✔
653
            # For lists, the metavar (either explicit or deduced) is the representation
654
            # of a single list member, so we turn the help string into a list of those here.
655
            return f'"[{metavar}, {metavar}, ...]"'
2✔
656
        return metavar
2✔
657

658
    @staticmethod
11✔
659
    def compute_choices(kwargs) -> tuple[str, ...] | None:
11✔
660
        """Compute the option choices to display."""
661
        typ = kwargs.get("type", [])
2✔
662
        member_type = kwargs.get("member_type", str)
2✔
663
        if typ == list and inspect.isclass(member_type) and issubclass(member_type, Enum):
2✔
UNCOV
664
            return tuple(choice.value for choice in member_type)
×
665
        elif inspect.isclass(typ) and issubclass(typ, Enum):
2✔
666
            return tuple(choice.value for choice in typ)
2✔
667
        elif "choices" in kwargs:
2✔
UNCOV
668
            return tuple(str(choice) for choice in kwargs["choices"])
×
669
        else:
670
            return None
2✔
671

672
    @staticmethod
11✔
673
    def get_provider(providers: tuple[str, ...] | None, hint: str | None = None) -> str:
11✔
674
        """Get the best match for the provider.
675

676
        To take advantage of provider names being modules, `hint` should be something like the a
677
        subsystem's `__module__` or a rule's `canonical_name`.
678
        """
679
        if not providers:
2✔
680
            return ""
2✔
681
        if not hint:
2✔
682
            # Pick the shortest backend name.
683
            return sorted(providers, key=len)[0]
×
684

685
        # Pick the one closest to `hint`.
686
        # No cutoff and max 1 result guarantees a result with a single element.
687
        [provider] = difflib.get_close_matches(hint, providers, n=1, cutoff=0.0)
2✔
688
        return provider
2✔
689

690
    @staticmethod
11✔
691
    def maybe_cleandoc(doc: str | None) -> str | None:
11✔
692
        return doc and inspect.cleandoc(doc)
×
693

694
    @staticmethod
11✔
695
    def get_module_docstring(filename: str) -> str:
11✔
UNCOV
696
        with open(filename) as fd:
×
UNCOV
697
            return ast.get_docstring(ast.parse(fd.read(), filename)) or ""
×
698

699
    @classmethod
11✔
700
    def get_rule_infos(
11✔
701
        cls, build_configuration: BuildConfiguration | None
702
    ) -> LazyFrozenDict[str, RuleInfo]:
703
        if build_configuration is None:
2✔
704
            return LazyFrozenDict({})
×
705

706
        def rule_info_loader(rule: TaskRule, provider: str) -> Callable[[], RuleInfo]:
2✔
707
            def load() -> RuleInfo:
2✔
708
                return RuleInfo.create(rule, provider)
1✔
709

710
            return load
2✔
711

712
        return LazyFrozenDict(
2✔
713
            {
714
                rule.canonical_name: rule_info_loader(
715
                    rule, cls.get_provider(providers, rule.canonical_name)
716
                )
717
                for rule, providers in build_configuration.rule_to_providers.items()
718
                if isinstance(rule, TaskRule)
719
            }
720
        )
721

722
    @classmethod
11✔
723
    def get_api_type_infos(
11✔
724
        cls, build_configuration: BuildConfiguration | None, union_membership: UnionMembership
725
    ) -> LazyFrozenDict[str, PluginAPITypeInfo]:
726
        if build_configuration is None:
2✔
727
            return LazyFrozenDict({})
×
728

729
        # The type narrowing achieved with the above `if` does not extend to the created closures of
730
        # the nested functions in this body, so instead we capture it in a new variable.
731
        bc = build_configuration
2✔
732

733
        known_providers = cast(
2✔
734
            "set[str]",
735
            set(
736
                chain.from_iterable(
737
                    chain.from_iterable(cast(dict, providers).values())
738
                    for providers in (
739
                        bc.subsystem_to_providers,
740
                        bc.target_type_to_providers,
741
                        bc.rule_to_providers,
742
                        bc.union_rule_to_providers,
743
                    )
744
                )
745
            ),
746
        )
747

748
        def _find_provider(api_type: type) -> str:
2✔
749
            provider = api_type.__module__
2✔
750
            while provider:
2✔
751
                if provider in known_providers:
2✔
752
                    return provider
×
753
                if "." not in provider:
2✔
754
                    break
2✔
755
                provider = provider.rsplit(".", 1)[0]
2✔
756
            # Unknown provider, depend directly on the type's module.
757
            return api_type.__module__
2✔
758

759
        def _rule_dependencies(rule: TaskRule) -> Iterator[type]:
2✔
760
            yield from rule.parameters.values()
2✔
761
            for constraint in rule.awaitables:
2✔
762
                yield constraint.output_type
2✔
763

764
        def _extract_api_types() -> Iterator[tuple[type, str, tuple[type, ...]]]:
2✔
765
            """Return all possible types we encounter in all known rules with provider and
766
            dependencies."""
767
            for rule, providers in bc.rule_to_providers.items():
2✔
768
                if not isinstance(rule, TaskRule):
2✔
769
                    continue
2✔
770
                provider = cls.get_provider(providers, rule.canonical_name)
2✔
771
                yield rule.output_type, provider, tuple(_rule_dependencies(rule))
2✔
772

773
                for constraint in rule.awaitables:
2✔
774
                    # TODO: For call-by-name, constraint.input_types only lists the types
775
                    # provided explicitly in a type map passed to **implicitly. Positional
776
                    # arguments don't appear in input_types. So this is no longer a robust
777
                    # way to get all the types. However, if a type is also available as
778
                    # some other rule's output type, we will still see it. This covers
779
                    # the important case for help output, namely "which rule should I
780
                    # call by name to get an instance of this type?"
781
                    for input_type in constraint.input_types:
2✔
782
                        yield input_type, _find_provider(input_type), ()
2✔
783

784
            union_bases: set[type] = set()
2✔
785
            for union_rule, providers in bc.union_rule_to_providers.items():
2✔
786
                provider = cls.get_provider(providers, union_rule.union_member.__module__)
2✔
787
                union_bases.add(union_rule.union_base)
2✔
788
                yield union_rule.union_member, provider, (union_rule.union_base,)
2✔
789

790
            for union_base in union_bases:
2✔
791
                yield union_base, _find_provider(union_base), ()
2✔
792

793
        all_types_with_dependencies = list(_extract_api_types())
2✔
794
        all_types = {api_type for api_type, _, _ in all_types_with_dependencies}
2✔
795
        type_graph: DefaultDict[type, dict[str, tuple[str, ...]]] = defaultdict(dict)
2✔
796

797
        # Calculate type graph.
798
        for api_type in all_types:
2✔
799
            # Collect all providers first, as we need them up-front for the dependencies/dependents.
800
            type_graph[api_type]["providers"] = tuple(
2✔
801
                sorted(
802
                    {
803
                        provider
804
                        for a_type, provider, _ in all_types_with_dependencies
805
                        if provider and a_type is api_type
806
                    }
807
                )
808
            )
809

810
        for api_type in all_types:
2✔
811
            # Resolve type dependencies to providers.
812
            type_graph[api_type]["dependencies"] = tuple(
2✔
813
                sorted(
814
                    set(
815
                        chain.from_iterable(
816
                            type_graph[dependency].setdefault(
817
                                "providers", (_find_provider(dependency),)
818
                            )
819
                            for a_type, _, dependencies in all_types_with_dependencies
820
                            if a_type is api_type
821
                            for dependency in dependencies
822
                        )
823
                    )
824
                    - set(
825
                        # Exclude providers from list of dependencies.
826
                        type_graph[api_type]["providers"]
827
                    )
828
                )
829
            )
830

831
        # Add a dependent on the target type for each dependency.
832
        type_dependents: DefaultDict[type, set[str]] = defaultdict(set)
2✔
833
        for _, provider, dependencies in all_types_with_dependencies:
2✔
834
            if not provider:
2✔
835
                continue
×
836
            for target_type in dependencies:
2✔
837
                type_dependents[target_type].add(provider)
2✔
838
        for api_type, dependents in type_dependents.items():
2✔
839
            type_graph[api_type]["dependents"] = tuple(
2✔
840
                sorted(
841
                    dependents
842
                    - set(
843
                        # Exclude providers from list of dependents.
844
                        type_graph[api_type]["providers"][0]
845
                    )
846
                )
847
            )
848

849
        rules = cast(
2✔
850
            "tuple[Rule | UnionRule]",
851
            tuple(
852
                chain(
853
                    bc.rule_to_providers.keys(),
854
                    bc.union_rule_to_providers.keys(),
855
                )
856
            ),
857
        )
858

859
        def get_api_type_info(api_types: tuple[type, ...]):
2✔
860
            """Gather the info from each of the types and aggregate it.
861

862
            The gathering is the expensive operation, and we can only aggregate once we've gathered.
863
            """
864

865
            def load() -> PluginAPITypeInfo:
2✔
UNCOV
866
                gatherered_infos = [
×
867
                    PluginAPITypeInfo.create(
868
                        api_type,
869
                        rules,
870
                        provider=type_graph[api_type]["providers"],
871
                        dependencies=type_graph[api_type]["dependencies"],
872
                        dependents=type_graph[api_type].get("dependents", ()),
873
                        union_members=tuple(
874
                            sorted(member.__qualname__ for member in union_membership.get(api_type))
875
                        ),
876
                    )
877
                    for api_type in api_types
878
                ]
UNCOV
879
                return reduce(lambda x, y: x.merged_with(y), gatherered_infos)
×
880

881
            return load
2✔
882

883
        # We want to provide a lazy dict so we don't spend so long doing the info gathering.
884
        # We provide a list of the types here, and the lookup function performs the gather and the aggregation
885
        api_type_name = PluginAPITypeInfo.fully_qualified_name_from_type
2✔
886
        all_types_sorted = sorted(all_types, key=api_type_name)
2✔
887
        infos: dict[str, Callable[[], PluginAPITypeInfo]] = {
2✔
888
            k: get_api_type_info(tuple(v))
889
            for k, v in itertools.groupby(all_types_sorted, key=api_type_name)
890
        }
891

892
        return LazyFrozenDict(infos)
2✔
893

894
    @classmethod
11✔
895
    def get_backend_help_info(cls, options: Options) -> LazyFrozenDict[str, BackendHelpInfo]:
11✔
896
        DiscoveredBackend = namedtuple(
2✔
897
            "DiscoveredBackend", "provider, name, register_py, enabled", defaults=(False,)
898
        )
899

900
        def discover_source_backends(root: Path, is_source_root: bool) -> set[DiscoveredBackend]:
2✔
901
            provider = root.name
2✔
902
            source_root = f"{root}/" if is_source_root else f"{root.parent}/"
2✔
903
            register_pys = root.glob("**/register.py")
2✔
904
            backends = {
2✔
905
                DiscoveredBackend(
906
                    provider,
907
                    str(register_py.parent).replace(source_root, "").replace("/", "."),
908
                    str(register_py),
909
                )
910
                for register_py in register_pys
911
            }
912
            return backends
2✔
913

914
        def discover_plugin_backends(entry_point_name: str) -> frozenset[DiscoveredBackend]:
2✔
915
            def get_dist_file(dist: importlib.metadata.Distribution, file_path: str):
2✔
UNCOV
916
                try:
×
UNCOV
917
                    return dist.locate_file(file_path)
×
918
                except Exception as e:
×
919
                    raise ValueError(
×
920
                        f"Failed to locate file `{file_path}` in distribution `{dist}`: {e}"
921
                    )
922

923
            backends = frozenset(
2✔
924
                DiscoveredBackend(
925
                    entry_point.dist.name,
926
                    entry_point.module,
927
                    str(
928
                        get_dist_file(
929
                            entry_point.dist, entry_point.module.replace(".", "/") + ".py"
930
                        )
931
                    ),
932
                    True,
933
                )
934
                for entry_point in importlib.metadata.entry_points().select(group=entry_point_name)
935
                if entry_point.dist is not None
936
            )
937
            return backends
2✔
938

939
        global_options = options.for_global_scope()
2✔
940
        builtin_backends = discover_source_backends(
2✔
941
            Path(pants.backend.__file__).parent.parent, is_source_root=False
942
        )
943
        inrepo_backends = chain.from_iterable(
2✔
944
            discover_source_backends(Path(path_entry), is_source_root=True)
945
            for path_entry in global_options.pythonpath
946
        )
947
        plugin_backends = discover_plugin_backends("pantsbuild.plugin")
2✔
948
        enabled_backends = {
2✔
949
            "pants.core",
950
            "pants.backend.project_info",
951
            *global_options.backend_packages,
952
        }
953

954
        def get_backend_help_info_loader(
2✔
955
            discovered_backend: DiscoveredBackend,
956
        ) -> Callable[[], BackendHelpInfo]:
957
            def load() -> BackendHelpInfo:
2✔
UNCOV
958
                return BackendHelpInfo(
×
959
                    name=discovered_backend.name,
960
                    description=cls.get_module_docstring(discovered_backend.register_py),
961
                    enabled=(
962
                        discovered_backend.enabled or discovered_backend.name in enabled_backends
963
                    ),
964
                    provider=discovered_backend.provider,
965
                )
966

967
            return load
2✔
968

969
        return LazyFrozenDict(
2✔
970
            {
971
                discovered_backend.name: get_backend_help_info_loader(discovered_backend)
972
                for discovered_backend in sorted(
973
                    chain(builtin_backends, inrepo_backends, plugin_backends)
974
                )
975
            }
976
        )
977

978
    @staticmethod
11✔
979
    def get_build_file_info(
11✔
980
        build_symbols: BuildFileSymbolsInfo,
981
    ) -> LazyFrozenDict[str, BuildFileSymbolHelpInfo]:
982
        def get_build_file_symbol_help_info_loader(
2✔
983
            symbol: BuildFileSymbolInfo,
984
        ) -> Callable[[], BuildFileSymbolHelpInfo]:
UNCOV
985
            def load() -> BuildFileSymbolHelpInfo:
×
UNCOV
986
                return BuildFileSymbolHelpInfo(
×
987
                    name=symbol.name,
988
                    is_target=isinstance(symbol.value, Registrar),
989
                    signature=symbol.signature,
990
                    documentation=symbol.help,
991
                )
992

UNCOV
993
            return load
×
994

995
        return LazyFrozenDict(
2✔
996
            {
997
                symbol.name: get_build_file_symbol_help_info_loader(symbol)
998
                for symbol in build_symbols.info.values()
999
                # (NB. we don't just check name.startswith("_") because there's symbols like
1000
                # __default__ that should appear in help & docs)
1001
                if not symbol.hide_from_help
1002
            }
1003
        )
1004

1005
    def __init__(self, scope: str):
11✔
1006
        self._scope = scope
2✔
1007
        self._scope_prefix = scope.replace(".", "-")
2✔
1008

1009
    def get_option_scope_help_info(
11✔
1010
        self,
1011
        description: str,
1012
        registrar: OptionRegistrar,
1013
        native_parser: NativeOptionParser,
1014
        is_goal: bool,
1015
        provider: str = "",
1016
        deprecated_scope: str | None = None,
1017
    ) -> OptionScopeHelpInfo:
1018
        """Returns an OptionScopeHelpInfo for the options parsed by the given parser."""
1019

1020
        basic_options = []
2✔
1021
        advanced_options = []
2✔
1022
        deprecated_options = []
2✔
1023
        for option_info in registrar.option_registrations_iter():
2✔
1024
            derivation = native_parser.get_derivation(registrar.scope, option_info)
2✔
1025
            # Massage the derivation structure returned by the NativeOptionParser into an
1026
            # OptionValueHistory as returned by the legacy parser.
1027
            # TODO: Once we get rid of the legacy parser we can probably simplify by
1028
            #  using the native structure directly.
1029
            ranked_values = []
2✔
1030

1031
            # Adding this constant, empty history entry is silly, but it appears in the
1032
            # legacy parser's results as an implementation artifact, and we want to be
1033
            # consistent with its tests until we get rid of it.
1034
            is_list = option_info.kwargs.get("type") == list
2✔
1035
            is_dict = option_info.kwargs.get("type") == dict
2✔
1036
            empty_val: list | dict | None = [] if is_list else {} if is_dict else None
2✔
1037
            empty_details = "" if (is_list or is_dict) else None
2✔
1038
            ranked_values.append(RankedValue(Rank.NONE, empty_val, empty_details))
2✔
1039

1040
            for value, rank, details in derivation:
2✔
1041
                ranked_values.append(RankedValue(rank, value, details or empty_details))
2✔
1042
            history = OptionValueHistory(tuple(ranked_values))
2✔
1043
            ohi = self.get_option_help_info(option_info)
2✔
1044
            ohi = dataclasses.replace(ohi, value_history=history)
2✔
1045
            if ohi.deprecation_active:
2✔
1046
                deprecated_options.append(ohi)
2✔
1047
            elif option_info.kwargs.get("advanced"):
2✔
1048
                advanced_options.append(ohi)
2✔
1049
            else:
1050
                basic_options.append(ohi)
2✔
1051

1052
        return OptionScopeHelpInfo(
2✔
1053
            scope=self._scope,
1054
            description=description,
1055
            provider=provider,
1056
            is_goal=is_goal,
1057
            deprecated_scope=deprecated_scope,
1058
            basic=tuple(basic_options),
1059
            advanced=tuple(advanced_options),
1060
            deprecated=tuple(deprecated_options),
1061
        )
1062

1063
    def get_option_help_info(self, option_info: OptionInfo) -> OptionHelpInfo:
11✔
1064
        """Returns an OptionHelpInfo for the option registered with the given (args, kwargs)."""
1065
        display_args = []
2✔
1066
        scoped_cmd_line_args = []
2✔
1067
        unscoped_cmd_line_args = []
2✔
1068

1069
        for arg in option_info.args:
2✔
1070
            is_short_arg = len(arg) == 2
2✔
1071
            unscoped_cmd_line_args.append(arg)
2✔
1072
            if self._scope_prefix:
2✔
1073
                scoped_arg = f"--{self._scope_prefix}-{arg.lstrip('-')}"
2✔
1074
            else:
1075
                scoped_arg = arg
2✔
1076
            scoped_cmd_line_args.append(scoped_arg)
2✔
1077

1078
            if OptionRegistrar.is_bool(option_info.kwargs):
2✔
1079
                if is_short_arg:
2✔
UNCOV
1080
                    display_args.append(scoped_arg)
×
1081
                else:
1082
                    unscoped_cmd_line_args.append(f"--no-{arg[2:]}")
2✔
1083
                    sa_2 = scoped_arg[2:]
2✔
1084
                    scoped_cmd_line_args.append(f"--no-{sa_2}")
2✔
1085
                    display_args.append(f"--[no-]{sa_2}")
2✔
1086
            else:
1087
                metavar = self.compute_metavar(option_info.kwargs)
2✔
1088
                separator = "" if is_short_arg else "="
2✔
1089
                display_args.append(f"{scoped_arg}{separator}{metavar}")
2✔
1090
                if option_info.kwargs.get("passthrough"):
2✔
UNCOV
1091
                    type_str = self.stringify_type(option_info.kwargs.get("member_type", str))
×
UNCOV
1092
                    display_args.append(f"... -- [{type_str} [{type_str} [...]]]")
×
1093

1094
        typ = option_info.kwargs.get("type", str)
2✔
1095
        default = self.compute_default(**option_info.kwargs)
2✔
1096
        help_msg = option_info.kwargs.get("help", "No help available.")
2✔
1097
        deprecation_start_version = option_info.kwargs.get("deprecation_start_version")
2✔
1098
        removal_version = option_info.kwargs.get("removal_version")
2✔
1099
        deprecation_active = removal_version is not None and deprecated.is_deprecation_active(
2✔
1100
            deprecation_start_version
1101
        )
1102
        deprecated_message = None
2✔
1103
        if removal_version:
2✔
1104
            deprecated_tense = deprecated.get_deprecated_tense(removal_version)
2✔
1105
            message_start = (
2✔
1106
                "Deprecated"
1107
                if deprecation_active
1108
                else f"Upcoming deprecation in version: {deprecation_start_version}"
1109
            )
1110
            deprecated_message = (
2✔
1111
                f"{message_start}, {deprecated_tense} removed in version: {removal_version}."
1112
            )
1113
        removal_hint = option_info.kwargs.get("removal_hint")
2✔
1114
        choices = self.compute_choices(option_info.kwargs)
2✔
1115

1116
        dest = parse_dest(option_info)
2✔
1117
        udest = dest.upper()
2✔
1118
        if self._scope == GLOBAL_SCOPE:
2✔
1119
            # Global options have 2-3 env var variants, e.g., --pants-workdir can be
1120
            # set with PANTS_GLOBAL_PANTS_WORKDIR, PANTS_PANTS_WORKDIR, or PANTS_WORKDIR.
1121
            # The last one is the most human-friendly, so it's what we use in the help info.
1122
            if udest.startswith("PANTS_"):
2✔
1123
                env_var = udest
2✔
1124
            else:
1125
                env_var = f"PANTS_{udest}"
2✔
1126
        else:
1127
            env_var = f"PANTS_{_ENV_SANITIZER_RE.sub('_', self._scope.upper())}_{udest}"
2✔
1128

1129
        target_field_name = (
2✔
1130
            f"{self._scope_prefix}_{option_field_name_for(option_info.args)}".replace("-", "_")
1131
        )
1132
        environment_aware = option_info.kwargs.get("environment_aware") is True
2✔
1133
        fromfile = option_info.kwargs.get("fromfile", False)
2✔
1134

1135
        ret = OptionHelpInfo(
2✔
1136
            display_args=tuple(display_args),
1137
            comma_separated_display_args=", ".join(display_args),
1138
            scoped_cmd_line_args=tuple(scoped_cmd_line_args),
1139
            unscoped_cmd_line_args=tuple(unscoped_cmd_line_args),
1140
            env_var=env_var,
1141
            config_key=dest,
1142
            target_field_name=target_field_name if environment_aware else None,
1143
            typ=typ,
1144
            default=default,
1145
            help=help_msg,
1146
            deprecation_active=deprecation_active,
1147
            deprecated_message=deprecated_message,
1148
            removal_version=removal_version,
1149
            removal_hint=removal_hint,
1150
            choices=choices,
1151
            comma_separated_choices=None if choices is None else ", ".join(choices),
1152
            value_history=None,
1153
            fromfile=fromfile,
1154
        )
1155
        return ret
2✔
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