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

pantsbuild / pants / 24055979590

06 Apr 2026 11:17PM UTC coverage: 52.37% (-40.5%) from 92.908%
24055979590

Pull #23225

github

web-flow
Merge 67474653c into 542ca048d
Pull Request #23225: Add --test-show-all-batch-targets to expose all targets in batched pytest

6 of 17 new or added lines in 2 files covered. (35.29%)

23030 existing lines in 605 files now uncovered.

31643 of 60422 relevant lines covered (52.37%)

1.05 hits per line

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

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

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

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

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

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

46

47
class HelpJSONEncoder(json.JSONEncoder):
2✔
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):
2✔
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:
2✔
UNCOV
65
    if isinstance(val, (list, dict)):
×
UNCOV
66
        return json.dumps(val, sort_keys=True, indent=2, cls=HelpJSONEncoder)
×
UNCOV
67
    if isinstance(val, Enum):
×
UNCOV
68
        return str(val.value)
×
69
    else:
UNCOV
70
        return str(val)
×
71

72

73
@dataclass(frozen=True)
2✔
74
class OptionHelpInfo:
2✔
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, ...]
2✔
102
    comma_separated_display_args: str
2✔
103
    scoped_cmd_line_args: tuple[str, ...]
2✔
104
    unscoped_cmd_line_args: tuple[str, ...]
2✔
105
    env_var: str
2✔
106
    config_key: str
2✔
107
    target_field_name: str | None
2✔
108
    typ: type
2✔
109
    default: Any
2✔
110
    help: str
2✔
111
    deprecation_active: bool
2✔
112
    deprecated_message: str | None
2✔
113
    removal_version: str | None
2✔
114
    removal_hint: str | None
2✔
115
    choices: tuple[str, ...] | None
2✔
116
    comma_separated_choices: str | None
2✔
117
    value_history: OptionValueHistory | None
2✔
118
    fromfile: bool
2✔
119

120

121
@dataclass(frozen=True)
2✔
122
class OptionScopeHelpInfo:
2✔
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
2✔
133
    description: str
2✔
134
    provider: str
2✔
135
    is_goal: bool  # True iff the scope belongs to a GoalSubsystem.
2✔
136
    deprecated_scope: str | None
2✔
137
    basic: tuple[OptionHelpInfo, ...]
2✔
138
    advanced: tuple[OptionHelpInfo, ...]
2✔
139
    deprecated: tuple[OptionHelpInfo, ...]
2✔
140

141
    def is_deprecated_scope(self):
2✔
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]:
2✔
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]:
2✔
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)
2✔
165
class GoalHelpInfo:
2✔
166
    """A container for help information for a goal."""
167

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

174

175
def pretty_print_type_hint(hint: Any) -> str:
2✔
UNCOV
176
    if getattr(hint, "__origin__", None) == Union:
×
UNCOV
177
        union_members = hint.__args__
×
UNCOV
178
        hint_str = " | ".join(pretty_print_type_hint(member) for member in union_members)
×
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.
UNCOV
182
    elif isinstance(hint, type) and not str(type(hint)) == "<class 'typing.GenericMeta'>":
×
UNCOV
183
        hint_str = hint.__name__
×
184
    else:
UNCOV
185
        hint_str = str(hint)
×
UNCOV
186
    return (
×
187
        hint_str.replace("collections.abc.", "").replace("typing.", "").replace("NoneType", "None")
188
    )
189

190

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

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

202
    @classmethod
2✔
203
    def create(cls, field: type[Field], *, provider: str) -> TargetFieldHelpInfo:
2✔
UNCOV
204
        hints = get_type_hints(field.compute_value)
×
UNCOV
205
        if "raw_value" in hints:
×
UNCOV
206
            type_hint = pretty_print_type_hint(hints["raw_value"])
×
207
        else:
UNCOV
208
            type_hint = field._raw_value_type
×
209

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

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

UNCOV
226
        return cls(
×
227
            alias=field.alias,
228
            provider=provider,
229
            description=strval(field.help),
230
            type_hint=type_hint,
231
            required=field.required,
232
            default=(
233
                repr(field.default) if (not field.required and field.default is not None) else None
234
            ),
235
        )
236

237

238
@dataclass(frozen=True)
2✔
239
class TargetTypeHelpInfo:
2✔
240
    """A container for help information for a target type."""
241

242
    alias: str
2✔
243
    provider: str
2✔
244
    summary: str
2✔
245
    description: str
2✔
246
    fields: tuple[TargetFieldHelpInfo, ...]
2✔
247

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

281

282
def maybe_cleandoc(doc: str | None) -> str | None:
2✔
UNCOV
283
    return doc and inspect.cleandoc(doc)
×
284

285

286
@dataclass(frozen=True)
2✔
287
class RuleInfo:
2✔
288
    """A container for help information for a rule.
289

290
    The `description` is the `desc` provided to the `@rule` decorator, and `documentation` is the
291
    rule's doc string.
292
    """
293

294
    name: str
2✔
295
    description: str | None
2✔
296
    documentation: str | None
2✔
297
    provider: str
2✔
298
    output_type: str
2✔
299
    input_types: tuple[str, ...]
2✔
300
    awaitables: tuple[str, ...]
2✔
301

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

314

315
@dataclass(frozen=True)
2✔
316
class PluginAPITypeInfo:
2✔
317
    """A container for help information for a plugin API type.
318

319
    Plugin API types are used as input parameters and output results for rules.
320
    """
321

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

335
    @property
2✔
336
    def fully_qualified_name(self) -> str:
2✔
337
        return f"{self.module}.{self.name}"
×
338

339
    @staticmethod
2✔
340
    def fully_qualified_name_from_type(t: type):
2✔
UNCOV
341
        return f"{t.__module__}.{t.__qualname__}"
×
342

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

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

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

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

373
    @staticmethod
2✔
374
    def _rule_consumes(api_type: type) -> Callable[[TaskRule], bool]:
2✔
UNCOV
375
        def satisfies(rule: TaskRule) -> bool:
×
UNCOV
376
            return api_type in rule.parameters.values()
×
377

UNCOV
378
        return satisfies
×
379

380
    @staticmethod
2✔
381
    def _rule_returns(api_type: type) -> Callable[[TaskRule], bool]:
2✔
UNCOV
382
        def satisfies(rule: TaskRule) -> bool:
×
UNCOV
383
            return rule.output_type is api_type
×
384

UNCOV
385
        return satisfies
×
386

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

UNCOV
395
        return satisfies
×
396

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

UNCOV
402
        return satisfies
×
403

404
    def merged_with(self, that: PluginAPITypeInfo) -> PluginAPITypeInfo:
2✔
405
        def merge_tuples(l, r):
×
406
            return tuple(sorted({*l, *r}))
×
407

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

423

424
@dataclass(frozen=True)
2✔
425
class BackendHelpInfo:
2✔
426
    name: str
2✔
427
    description: str
2✔
428
    enabled: bool
2✔
429
    provider: str
2✔
430

431

432
@dataclass(frozen=True)
2✔
433
class BuildFileSymbolHelpInfo:
2✔
434
    name: str
2✔
435
    is_target: bool
2✔
436
    signature: str | None
2✔
437
    documentation: str | None
2✔
438

439

440
@dataclass(frozen=True)
2✔
441
class AllHelpInfo:
2✔
442
    """All available help info."""
443

444
    scope_to_help_info: LazyFrozenDict[str, OptionScopeHelpInfo]
2✔
445
    name_to_goal_info: LazyFrozenDict[str, GoalHelpInfo]
2✔
446
    name_to_target_type_info: LazyFrozenDict[str, TargetTypeHelpInfo]
2✔
447
    name_to_rule_info: LazyFrozenDict[str, RuleInfo]
2✔
448
    name_to_api_type_info: LazyFrozenDict[str, PluginAPITypeInfo]
2✔
449
    name_to_backend_help_info: LazyFrozenDict[str, BackendHelpInfo]
2✔
450
    name_to_build_file_info: LazyFrozenDict[str, BuildFileSymbolHelpInfo]
2✔
451
    env_var_to_help_info: LazyFrozenDict[str, OptionHelpInfo]
2✔
452

453
    def non_deprecated_option_scope_help_infos(self):
2✔
454
        for oshi in self.scope_to_help_info.values():
×
455
            if not oshi.is_deprecated_scope():
×
456
                yield oshi
×
457

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

464

465
ConsumedScopesMapper = Callable[[str], tuple[str, ...]]
2✔
466

467

468
class HelpInfoExtracter:
2✔
469
    """Extracts information useful for displaying help from option registration args."""
470

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

UNCOV
513
            return load
×
514

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

UNCOV
533
            return load
×
534

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

UNCOV
558
            return load
×
559

UNCOV
560
        def lazily(value: T) -> Callable[[], T]:
×
UNCOV
561
            return lambda: value
×
562

UNCOV
563
        known_scope_infos = sorted(options.known_scope_to_info.values(), key=lambda x: x.scope)
×
UNCOV
564
        scope_to_help_info = LazyFrozenDict(
×
565
            {
566
                scope_info.scope: option_scope_help_info_loader_for(scope_info)
567
                for scope_info in known_scope_infos
568
                if not scope_info.scope.startswith("_")
569
            }
570
        )
571

UNCOV
572
        env_var_to_help_info = LazyFrozenDict(
×
573
            {
574
                ohi.env_var: lazily(ohi)
575
                for oshi in scope_to_help_info.values()
576
                for ohi in chain(oshi.basic, oshi.advanced, oshi.deprecated)
577
            }
578
        )
579

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

UNCOV
594
        name_to_target_type_info = LazyFrozenDict(
×
595
            {
596
                alias: target_type_info_for(target_type)
597
                for alias, target_type in registered_target_types.aliases_to_types.items()
598
                if (target_type.removal_version is None and alias != target_type.deprecated_alias)
599
            }
600
        )
601

UNCOV
602
        return AllHelpInfo(
×
603
            scope_to_help_info=scope_to_help_info,
604
            name_to_goal_info=name_to_goal_info,
605
            name_to_target_type_info=name_to_target_type_info,
606
            name_to_rule_info=cls.get_rule_infos(build_configuration),
607
            name_to_api_type_info=cls.get_api_type_infos(build_configuration, union_membership),
608
            name_to_backend_help_info=cls.get_backend_help_info(options),
609
            name_to_build_file_info=cls.get_build_file_info(build_symbols),
610
            env_var_to_help_info=env_var_to_help_info,
611
        )
612

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

UNCOV
622
        default = kwargs.get("default")
×
UNCOV
623
        if default is not None:
×
UNCOV
624
            return default
×
UNCOV
625
        if is_list_option(kwargs):
×
UNCOV
626
            return []
×
UNCOV
627
        elif is_dict_option(kwargs):
×
UNCOV
628
            return {}
×
UNCOV
629
        return None
×
630

631
    @staticmethod
2✔
632
    def stringify_type(t: type) -> str:
2✔
UNCOV
633
        if t == dict:
×
UNCOV
634
            return "{'key1': val1, 'key2': val2, ...}"
×
UNCOV
635
        return f"<{t.__name__}>"
×
636

637
    @staticmethod
2✔
638
    def compute_metavar(kwargs):
2✔
639
        """Compute the metavar to display in help for an option registered with these kwargs."""
640

UNCOV
641
        stringify = HelpInfoExtracter.stringify_type
×
642

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

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

675
    @staticmethod
2✔
676
    def get_provider(providers: tuple[str, ...] | None, hint: str | None = None) -> str:
2✔
677
        """Get the best match for the provider.
678

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

688
        # Pick the one closest to `hint`.
689
        # No cutoff and max 1 result guarantees a result with a single element.
UNCOV
690
        [provider] = difflib.get_close_matches(hint, providers, n=1, cutoff=0.0)
×
UNCOV
691
        return provider
×
692

693
    @staticmethod
2✔
694
    def maybe_cleandoc(doc: str | None) -> str | None:
2✔
695
        return doc and inspect.cleandoc(doc)
×
696

697
    @staticmethod
2✔
698
    def get_module_docstring(filename: str) -> str:
2✔
UNCOV
699
        with open(filename) as fd:
×
UNCOV
700
            return ast.get_docstring(ast.parse(fd.read(), filename)) or ""
×
701

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

UNCOV
709
        def rule_info_loader(rule: TaskRule, provider: str) -> Callable[[], RuleInfo]:
×
UNCOV
710
            def load() -> RuleInfo:
×
UNCOV
711
                return RuleInfo.create(rule, provider)
×
712

UNCOV
713
            return load
×
714

UNCOV
715
        return LazyFrozenDict(
×
716
            {
717
                rule.canonical_name: rule_info_loader(
718
                    rule, cls.get_provider(providers, rule.canonical_name)
719
                )
720
                for rule, providers in build_configuration.rule_to_providers.items()
721
                if isinstance(rule, TaskRule)
722
            }
723
        )
724

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

732
        # The type narrowing achieved with the above `if` does not extend to the created closures of
733
        # the nested functions in this body, so instead we capture it in a new variable.
UNCOV
734
        bc = build_configuration
×
735

UNCOV
736
        known_providers = cast(
×
737
            "set[str]",
738
            set(
739
                chain.from_iterable(
740
                    chain.from_iterable(cast(dict, providers).values())
741
                    for providers in (
742
                        bc.subsystem_to_providers,
743
                        bc.target_type_to_providers,
744
                        bc.rule_to_providers,
745
                        bc.union_rule_to_providers,
746
                    )
747
                )
748
            ),
749
        )
750

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

UNCOV
762
        def _rule_dependencies(rule: TaskRule) -> Iterator[type]:
×
UNCOV
763
            yield from rule.parameters.values()
×
UNCOV
764
            for constraint in rule.awaitables:
×
UNCOV
765
                yield constraint.output_type
×
766

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

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

UNCOV
787
            union_bases: set[type] = set()
×
UNCOV
788
            for union_rule, providers in bc.union_rule_to_providers.items():
×
UNCOV
789
                provider = cls.get_provider(providers, union_rule.union_member.__module__)
×
UNCOV
790
                union_bases.add(union_rule.union_base)
×
UNCOV
791
                yield union_rule.union_member, provider, (union_rule.union_base,)
×
792

UNCOV
793
            for union_base in union_bases:
×
UNCOV
794
                yield union_base, _find_provider(union_base), ()
×
795

UNCOV
796
        all_types_with_dependencies = list(_extract_api_types())
×
UNCOV
797
        all_types = {api_type for api_type, _, _ in all_types_with_dependencies}
×
UNCOV
798
        type_graph: DefaultDict[type, dict[str, tuple[str, ...]]] = defaultdict(dict)
×
799

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

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

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

UNCOV
852
        rules = cast(
×
853
            "tuple[Rule | UnionRule]",
854
            tuple(
855
                chain(
856
                    bc.rule_to_providers.keys(),
857
                    bc.union_rule_to_providers.keys(),
858
                )
859
            ),
860
        )
861

UNCOV
862
        def get_api_type_info(api_types: tuple[type, ...]):
×
863
            """Gather the info from each of the types and aggregate it.
864

865
            The gathering is the expensive operation, and we can only aggregate once we've gathered.
866
            """
867

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

UNCOV
884
            return load
×
885

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

UNCOV
895
        return LazyFrozenDict(infos)
×
896

897
    @classmethod
2✔
898
    def get_backend_help_info(cls, options: Options) -> LazyFrozenDict[str, BackendHelpInfo]:
2✔
UNCOV
899
        DiscoveredBackend = namedtuple(
×
900
            "DiscoveredBackend", "provider, name, register_py, enabled", defaults=(False,)
901
        )
902

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

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

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

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

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

UNCOV
970
            return load
×
971

UNCOV
972
        return LazyFrozenDict(
×
973
            {
974
                discovered_backend.name: get_backend_help_info_loader(discovered_backend)
975
                for discovered_backend in sorted(
976
                    chain(builtin_backends, inrepo_backends, plugin_backends)
977
                )
978
            }
979
        )
980

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

UNCOV
996
            return load
×
997

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

1008
    def __init__(self, scope: str):
2✔
UNCOV
1009
        self._scope = scope
×
UNCOV
1010
        self._scope_prefix = scope.replace(".", "-")
×
1011

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

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

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

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

UNCOV
1055
        return OptionScopeHelpInfo(
×
1056
            scope=self._scope,
1057
            description=description,
1058
            provider=provider,
1059
            is_goal=is_goal,
1060
            deprecated_scope=deprecated_scope,
1061
            basic=tuple(basic_options),
1062
            advanced=tuple(advanced_options),
1063
            deprecated=tuple(deprecated_options),
1064
        )
1065

1066
    def get_option_help_info(self, option_info: OptionInfo) -> OptionHelpInfo:
2✔
1067
        """Returns an OptionHelpInfo for the option registered with the given (args, kwargs)."""
UNCOV
1068
        display_args = []
×
UNCOV
1069
        scoped_cmd_line_args = []
×
UNCOV
1070
        unscoped_cmd_line_args = []
×
1071

UNCOV
1072
        for arg in option_info.args:
×
UNCOV
1073
            is_short_arg = len(arg) == 2
×
UNCOV
1074
            unscoped_cmd_line_args.append(arg)
×
UNCOV
1075
            if self._scope_prefix:
×
UNCOV
1076
                scoped_arg = f"--{self._scope_prefix}-{arg.lstrip('-')}"
×
1077
            else:
UNCOV
1078
                scoped_arg = arg
×
UNCOV
1079
            scoped_cmd_line_args.append(scoped_arg)
×
1080

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

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

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

UNCOV
1132
        target_field_name = (
×
1133
            f"{self._scope_prefix}_{option_field_name_for(option_info.args)}".replace("-", "_")
1134
        )
UNCOV
1135
        environment_aware = option_info.kwargs.get("environment_aware") is True
×
UNCOV
1136
        fromfile = option_info.kwargs.get("fromfile", False)
×
1137

UNCOV
1138
        ret = OptionHelpInfo(
×
1139
            display_args=tuple(display_args),
1140
            comma_separated_display_args=", ".join(display_args),
1141
            scoped_cmd_line_args=tuple(scoped_cmd_line_args),
1142
            unscoped_cmd_line_args=tuple(unscoped_cmd_line_args),
1143
            env_var=env_var,
1144
            config_key=dest,
1145
            target_field_name=target_field_name if environment_aware else None,
1146
            typ=typ,
1147
            default=default,
1148
            help=help_msg,
1149
            deprecation_active=deprecation_active,
1150
            deprecated_message=deprecated_message,
1151
            removal_version=removal_version,
1152
            removal_hint=removal_hint,
1153
            choices=choices,
1154
            comma_separated_choices=None if choices is None else ", ".join(choices),
1155
            value_history=None,
1156
            fromfile=fromfile,
1157
        )
UNCOV
1158
        return ret
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc