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

pantsbuild / pants / 26260209689

21 May 2026 11:59PM UTC coverage: 75.453% (-15.7%) from 91.156%
26260209689

Pull #23365

github

web-flow
Merge 5fe873b58 into 7ea655ba0
Pull Request #23365: uv.lock -> pex optimization

5 of 16 new or added lines in 1 file covered. (31.25%)

10118 existing lines in 378 files now uncovered.

54669 of 72454 relevant lines covered (75.45%)

2.31 hits per line

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

90.7
/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
5✔
5

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

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

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

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

46

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

72

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

120

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

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

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

174

175
def pretty_print_type_hint(hint: Any) -> str:
5✔
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)
5✔
192
class TargetFieldHelpInfo:
5✔
193
    """A container for help information for a field in a target type."""
194

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

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

210
        # Check if the field only allows for certain choices.
211
        if issubclass(field, StringField) and field.valid_choices is not None:
1✔
UNCOV
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
            )
UNCOV
217
            type_hint = " | ".join([*(repr(c) for c in valid_choices), "None"])
×
218

219
        if field.required:
1✔
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.
224
            type_hint = type_hint.replace(" | None", "")
1✔
225

226
        return cls(
1✔
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)
5✔
239
class TargetTypeHelpInfo:
5✔
240
    """A container for help information for a target type."""
241

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

248
    @classmethod
5✔
249
    def create(
5✔
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:
257
        fields = list(target_type.class_field_types(union_membership=union_membership))
1✔
258
        if issubclass(target_type, TargetGenerator):
1✔
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)
×
263
        helpstr = strval(target_type.help)
1✔
264
        return cls(
1✔
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:
5✔
283
    return doc and inspect.cleandoc(doc)
1✔
284

285

286
@dataclass(frozen=True)
5✔
287
class RuleInfo:
5✔
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
5✔
295
    description: str | None
5✔
296
    documentation: str | None
5✔
297
    provider: str
5✔
298
    output_type: str
5✔
299
    input_types: tuple[str, ...]
5✔
300
    awaitables: tuple[str, ...]
5✔
301

302
    @classmethod
5✔
303
    def create(cls, rule: TaskRule, provider: str) -> RuleInfo:
5✔
304
        return cls(
1✔
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)
5✔
316
class PluginAPITypeInfo:
5✔
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
5✔
323
    module: str
5✔
324
    documentation: str | None
5✔
325
    provider: tuple[str, ...]
5✔
326
    is_union: bool
5✔
327
    union_type: str | None
5✔
328
    union_members: tuple[str, ...]
5✔
329
    dependencies: tuple[str, ...]
5✔
330
    dependents: tuple[str, ...]
5✔
331
    returned_by_rules: tuple[str, ...]
5✔
332
    consumed_by_rules: tuple[str, ...]
5✔
333
    used_in_rules: tuple[str, ...]
5✔
334

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

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

343
    @classmethod
5✔
344
    def create(
5✔
345
        cls, api_type: type, rules: Sequence[Rule | UnionRule], **kwargs
346
    ) -> PluginAPITypeInfo:
347
        union_type: str | None = None
1✔
348
        for rule in filter(cls._member_type_for(api_type), rules):
1✔
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

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

355
        return cls(
1✔
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
5✔
368
    def _all_rules(
5✔
369
        satisfies: Callable[[TaskRule], bool], rules: Sequence[TaskRule]
370
    ) -> tuple[str, ...]:
371
        return tuple(sorted(rule.canonical_name for rule in filter(satisfies, rules)))
1✔
372

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

378
        return satisfies
1✔
379

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

385
        return satisfies
1✔
386

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

395
        return satisfies
1✔
396

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

402
        return satisfies
1✔
403

404
    def merged_with(self, that: PluginAPITypeInfo) -> PluginAPITypeInfo:
5✔
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)
5✔
425
class BackendHelpInfo:
5✔
426
    name: str
5✔
427
    description: str
5✔
428
    enabled: bool
5✔
429
    provider: str
5✔
430

431

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

439

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

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

453
    def non_deprecated_option_scope_help_infos(self):
5✔
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]:
5✔
459
        return {
1✔
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, ...]]
5✔
466

467

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

471
    @classmethod
5✔
472
    def get_all_help_info(
5✔
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:
481
        def option_scope_help_info_loader_for(
1✔
482
            scope_info: ScopeInfo,
483
        ) -> Callable[[], OptionScopeHelpInfo]:
484
            def load() -> OptionScopeHelpInfo:
1✔
485
                subsystem_cls = scope_info.subsystem_cls
1✔
486
                if not scope_info.description:
1✔
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
                    )
496
                provider = ""
1✔
497
                if subsystem_cls is not None and build_configuration is not None:
1✔
498
                    provider = cls.get_provider(
1✔
499
                        build_configuration.subsystem_to_providers.get(subsystem_cls),
500
                        subsystem_cls.__module__,
501
                    )
502
                return HelpInfoExtracter(scope_info.scope).get_option_scope_help_info(
1✔
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

513
            return load
1✔
514

515
        def goal_help_info_loader_for(scope_info: ScopeInfo) -> Callable[[], GoalHelpInfo]:
1✔
516
            def load() -> GoalHelpInfo:
1✔
517
                subsystem_cls = scope_info.subsystem_cls
1✔
518
                assert subsystem_cls is not None
1✔
519
                if build_configuration is not None:
1✔
520
                    provider = cls.get_provider(
1✔
521
                        build_configuration.subsystem_to_providers.get(subsystem_cls),
522
                        subsystem_cls.__module__,
523
                    )
524
                goal_subsystem_cls = cast(type[GoalSubsystem], subsystem_cls)
1✔
525
                return GoalHelpInfo(
1✔
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

533
            return load
1✔
534

535
        def target_type_info_for(target_type: type[Target]) -> Callable[[], TargetTypeHelpInfo]:
1✔
536
            def load() -> TargetTypeHelpInfo:
1✔
537
                return TargetTypeHelpInfo.create(
1✔
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

558
            return load
1✔
559

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

563
        known_scope_infos = sorted(options.known_scope_to_info.values(), key=lambda x: x.scope)
1✔
564
        scope_to_help_info = LazyFrozenDict(
1✔
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

572
        env_var_to_help_info = LazyFrozenDict(
1✔
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

580
        name_to_goal_info = LazyFrozenDict(
1✔
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

594
        name_to_target_type_info = LazyFrozenDict(
1✔
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

602
        return AllHelpInfo(
1✔
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
5✔
614
    def compute_default(**kwargs) -> Any:
5✔
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.
618
        default_help_repr = kwargs.get("default_help_repr")
1✔
619
        if default_help_repr is not None:
1✔
620
            return str(default_help_repr)  # Should already be a string, but might as well be safe.
1✔
621

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

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

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

641
        stringify = HelpInfoExtracter.stringify_type
1✔
642

643
        metavar = kwargs.get("metavar")
1✔
644
        if not metavar:
1✔
645
            if is_list_option(kwargs):
1✔
646
                member_typ = kwargs.get("member_type", str)
1✔
647
                metavar = stringify(member_typ)
1✔
648
                # In a cmd-line list literal, string members must be quoted.
649
                if member_typ == str:
1✔
650
                    metavar = f"'{metavar}'"
1✔
651
            elif is_dict_option(kwargs):
1✔
652
                metavar = f'"{stringify(dict)}"'
1✔
653
            else:
654
                metavar = stringify(kwargs.get("type", str))
1✔
655
        if is_list_option(kwargs):
1✔
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.
658
            return f'"[{metavar}, {metavar}, ...]"'
1✔
659
        return metavar
1✔
660

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

675
    @staticmethod
5✔
676
    def get_provider(providers: tuple[str, ...] | None, hint: str | None = None) -> str:
5✔
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
        """
682
        if not providers:
1✔
683
            return ""
1✔
684
        if not hint:
1✔
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.
690
        [provider] = difflib.get_close_matches(hint, providers, n=1, cutoff=0.0)
1✔
691
        return provider
1✔
692

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

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

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

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

713
            return load
1✔
714

715
        return LazyFrozenDict(
1✔
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
5✔
726
    def get_api_type_infos(
5✔
727
        cls, build_configuration: BuildConfiguration | None, union_membership: UnionMembership
728
    ) -> LazyFrozenDict[str, PluginAPITypeInfo]:
729
        if build_configuration is None:
1✔
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.
734
        bc = build_configuration
1✔
735

736
        known_providers = cast(
1✔
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

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

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

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

776
                for constraint in rule.awaitables:
1✔
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?"
784
                    for input_type in constraint.input_types:
1✔
UNCOV
785
                        yield input_type, _find_provider(input_type), ()
×
786

787
            union_bases: set[type] = set()
1✔
788
            for union_rule, providers in bc.union_rule_to_providers.items():
1✔
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

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

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

800
        # Calculate type graph.
801
        for api_type in all_types:
1✔
802
            # Collect all providers first, as we need them up-front for the dependencies/dependents.
803
            type_graph[api_type]["providers"] = tuple(
1✔
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

813
        for api_type in all_types:
1✔
814
            # Resolve type dependencies to providers.
815
            type_graph[api_type]["dependencies"] = tuple(
1✔
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.
835
        type_dependents: DefaultDict[type, set[str]] = defaultdict(set)
1✔
836
        for _, provider, dependencies in all_types_with_dependencies:
1✔
837
            if not provider:
1✔
838
                continue
×
839
            for target_type in dependencies:
1✔
840
                type_dependents[target_type].add(provider)
1✔
841
        for api_type, dependents in type_dependents.items():
1✔
842
            type_graph[api_type]["dependents"] = tuple(
1✔
843
                sorted(
844
                    dependents
845
                    - set(
846
                        # Exclude providers from list of dependents.
847
                        type_graph[api_type]["providers"][0]
848
                    )
849
                )
850
            )
851

852
        rules = cast(
1✔
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

862
        def get_api_type_info(api_types: tuple[type, ...]):
1✔
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

868
            def load() -> PluginAPITypeInfo:
1✔
869
                gatherered_infos = [
1✔
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
                ]
882
                return reduce(lambda x, y: x.merged_with(y), gatherered_infos)
1✔
883

884
            return load
1✔
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
888
        api_type_name = PluginAPITypeInfo.fully_qualified_name_from_type
1✔
889
        all_types_sorted = sorted(all_types, key=api_type_name)
1✔
890
        infos: dict[str, Callable[[], PluginAPITypeInfo]] = {
1✔
891
            k: get_api_type_info(tuple(v))
892
            for k, v in itertools.groupby(all_types_sorted, key=api_type_name)
893
        }
894

895
        return LazyFrozenDict(infos)
1✔
896

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

903
        def discover_source_backends(root: Path, is_source_root: bool) -> set[DiscoveredBackend]:
1✔
904
            provider = root.name
1✔
905
            source_root = f"{root}/" if is_source_root else f"{root.parent}/"
1✔
906
            register_pys = root.glob("**/register.py")
1✔
907
            backends = {
1✔
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
            }
915
            return backends
1✔
916

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

926
            backends = frozenset(
1✔
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
            )
940
            return backends
1✔
941

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

957
        def get_backend_help_info_loader(
1✔
958
            discovered_backend: DiscoveredBackend,
959
        ) -> Callable[[], BackendHelpInfo]:
960
            def load() -> BackendHelpInfo:
1✔
961
                return BackendHelpInfo(
1✔
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

970
            return load
1✔
971

972
        return LazyFrozenDict(
1✔
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
5✔
982
    def get_build_file_info(
5✔
983
        build_symbols: BuildFileSymbolsInfo,
984
    ) -> LazyFrozenDict[str, BuildFileSymbolHelpInfo]:
985
        def get_build_file_symbol_help_info_loader(
1✔
986
            symbol: BuildFileSymbolInfo,
987
        ) -> Callable[[], BuildFileSymbolHelpInfo]:
988
            def load() -> BuildFileSymbolHelpInfo:
1✔
989
                return BuildFileSymbolHelpInfo(
1✔
990
                    name=symbol.name,
991
                    is_target=isinstance(symbol.value, Registrar),
992
                    signature=symbol.signature,
993
                    documentation=symbol.help,
994
                )
995

996
            return load
1✔
997

998
        return LazyFrozenDict(
1✔
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):
5✔
1009
        self._scope = scope
1✔
1010
        self._scope_prefix = scope.replace(".", "-")
1✔
1011

1012
    def get_option_scope_help_info(
5✔
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

1023
        basic_options = []
1✔
1024
        advanced_options = []
1✔
1025
        deprecated_options = []
1✔
1026
        for option_info in registrar.option_registrations_iter():
1✔
1027
            derivation = native_parser.get_derivation(registrar.scope, option_info)
1✔
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.
1032
            ranked_values = []
1✔
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.
1037
            is_list = option_info.kwargs.get("type") == list
1✔
1038
            is_dict = option_info.kwargs.get("type") == dict
1✔
1039
            empty_val: list | dict | None = [] if is_list else {} if is_dict else None
1✔
1040
            empty_details = "" if (is_list or is_dict) else None
1✔
1041
            ranked_values.append(RankedValue(Rank.NONE, empty_val, empty_details))
1✔
1042

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

1055
        return OptionScopeHelpInfo(
1✔
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:
5✔
1067
        """Returns an OptionHelpInfo for the option registered with the given (args, kwargs)."""
1068
        display_args = []
1✔
1069
        scoped_cmd_line_args = []
1✔
1070
        unscoped_cmd_line_args = []
1✔
1071

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

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

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

1119
        dest = parse_dest(option_info)
1✔
1120
        udest = dest.upper()
1✔
1121
        if self._scope == GLOBAL_SCOPE:
1✔
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.
1125
            if udest.startswith("PANTS_"):
1✔
UNCOV
1126
                env_var = udest
×
1127
            else:
1128
                env_var = f"PANTS_{udest}"
1✔
1129
        else:
1130
            env_var = f"PANTS_{_ENV_SANITIZER_RE.sub('_', self._scope.upper())}_{udest}"
1✔
1131

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

1138
        ret = OptionHelpInfo(
1✔
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
        )
1158
        return ret
1✔
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