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

pantsbuild / pants / 22898465085

10 Mar 2026 10:37AM UTC coverage: 92.932%. Remained the same
22898465085

Pull #23032

github

web-flow
Merge 4bf2e958f into 2804a4673
Pull Request #23032: Bugfix: Add support for pull option in podman

87 of 93 new or added lines in 4 files covered. (93.55%)

2 existing lines in 2 files now uncovered.

91041 of 97965 relevant lines covered (92.93%)

4.06 hits per line

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

92.44
/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
12✔
5

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

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

49
T = TypeVar("T")
12✔
50

51
_ENV_SANITIZER_RE = re.compile(r"[.-]")
12✔
52

53

54
class HelpJSONEncoder(json.JSONEncoder):
12✔
55
    """Class for JSON-encoding help data (including option values).
56

57
    Note that JSON-encoded data is not intended to be decoded back. It exists purely for terminal
58
    and browser help display.
59
    """
60

61
    def default(self, o):
12✔
62
        if callable(o):
×
63
            return o.__name__
×
64
        if isinstance(o, type):
×
65
            return type.__name__
×
66
        if isinstance(o, Enum):
×
67
            return o.value
×
68
        return super().default(o)
×
69

70

71
def to_help_str(val) -> str:
12✔
72
    if isinstance(val, (list, dict)):
2✔
73
        return json.dumps(val, sort_keys=True, indent=2, cls=HelpJSONEncoder)
1✔
74
    if isinstance(val, Enum):
2✔
75
        return str(val.value)
1✔
76
    else:
77
        return str(val)
2✔
78

79

80
@dataclass(frozen=True)
12✔
81
class OptionHelpInfo:
12✔
82
    """A container for help information for a single option.
83

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

108
    display_args: tuple[str, ...]
12✔
109
    comma_separated_display_args: str
12✔
110
    scoped_cmd_line_args: tuple[str, ...]
12✔
111
    unscoped_cmd_line_args: tuple[str, ...]
12✔
112
    env_var: str
12✔
113
    config_key: str
12✔
114
    target_field_name: str | None
12✔
115
    typ: type
12✔
116
    default: Any
12✔
117
    help: str
12✔
118
    deprecation_active: bool
12✔
119
    deprecated_message: str | None
12✔
120
    removal_version: str | None
12✔
121
    removal_hint: str | None
12✔
122
    choices: tuple[str, ...] | None
12✔
123
    comma_separated_choices: str | None
12✔
124
    value_history: OptionValueHistory | None
12✔
125
    fromfile: bool
12✔
126

127

128
@dataclass(frozen=True)
12✔
129
class OptionScopeHelpInfo:
12✔
130
    """A container for help information for a scope of options.
131

132
    scope: The scope of the described options.
133
    provider: Which backend or plugin registered this scope.
134
    deprecated_scope: A deprecated scope name for this scope. A scope that has a deprecated scope
135
      will be represented by two objects - one of which will have scope==deprecated_scope.
136
    basic|advanced|deprecated: A list of OptionHelpInfo for the options in that group.
137
    """
138

139
    scope: str
12✔
140
    description: str
12✔
141
    provider: str
12✔
142
    is_goal: bool  # True iff the scope belongs to a GoalSubsystem.
12✔
143
    deprecated_scope: str | None
12✔
144
    basic: tuple[OptionHelpInfo, ...]
12✔
145
    advanced: tuple[OptionHelpInfo, ...]
12✔
146
    deprecated: tuple[OptionHelpInfo, ...]
12✔
147

148
    def is_deprecated_scope(self):
12✔
149
        """Returns True iff this scope is deprecated.
150

151
        We may choose not to show deprecated scopes when enumerating scopes, but still want to show
152
        help for individual deprecated scopes when explicitly requested.
153
        """
154
        return self.scope == self.deprecated_scope
×
155

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

163
    def collect_scoped_flags(self) -> list[str]:
12✔
164
        flags: list[str] = []
×
165
        for options in (self.basic, self.advanced, self.deprecated):
×
166
            for ohi in options:
×
167
                flags.extend(ohi.scoped_cmd_line_args)
×
168
        return flags
×
169

170

171
@dataclass(frozen=True)
12✔
172
class GoalHelpInfo:
12✔
173
    """A container for help information for a goal."""
174

175
    name: str
12✔
176
    description: str
12✔
177
    provider: str
12✔
178
    is_implemented: bool  # True iff all unions required by the goal are implemented.
12✔
179
    consumed_scopes: tuple[str, ...]  # The scopes of subsystems consumed by this goal.
12✔
180

181

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

197

198
@dataclass(frozen=True)
12✔
199
class TargetFieldHelpInfo:
12✔
200
    """A container for help information for a field in a target type."""
201

202
    alias: str
12✔
203
    provider: str
12✔
204
    description: str
12✔
205
    type_hint: str
12✔
206
    required: bool
12✔
207
    default: str | None
12✔
208

209
    @classmethod
12✔
210
    def create(cls, field: type[Field], *, provider: str) -> TargetFieldHelpInfo:
12✔
211
        raw_value_type = get_type_hints(field.compute_value)["raw_value"]
2✔
212
        type_hint = pretty_print_type_hint(raw_value_type)
2✔
213

214
        # Check if the field only allows for certain choices.
215
        if issubclass(field, (StringField, StringOrBoolField)) and field.valid_choices is not None:
2✔
216
            valid_choices = sorted(
1✔
217
                field.valid_choices
218
                if isinstance(field.valid_choices, tuple)
219
                else (choice.value for choice in field.valid_choices)
220
            )
221
            if issubclass(field, StringOrBoolField):
1✔
222
                type_hint = " | ".join([*(repr(c) for c in valid_choices), "bool", "None"])
1✔
223
            else:
NEW
224
                type_hint = " | ".join([*(repr(c) for c in valid_choices), "None"])
×
225

226
        if field.required:
2✔
227
            # We hackily remove `None` as a valid option for the field when it's required. This
228
            # greatly simplifies Field definitions because it means that they don't need to
229
            # override the type hints for `PrimitiveField.compute_value()` and
230
            # `AsyncField.sanitize_raw_value()` to indicate that `None` is an invalid type.
231
            type_hint = type_hint.replace(" | None", "")
1✔
232

233
        return cls(
2✔
234
            alias=field.alias,
235
            provider=provider,
236
            description=strval(field.help),
237
            type_hint=type_hint,
238
            required=field.required,
239
            default=(
240
                repr(field.default) if (not field.required and field.default is not None) else None
241
            ),
242
        )
243

244

245
@dataclass(frozen=True)
12✔
246
class TargetTypeHelpInfo:
12✔
247
    """A container for help information for a target type."""
248

249
    alias: str
12✔
250
    provider: str
12✔
251
    summary: str
12✔
252
    description: str
12✔
253
    fields: tuple[TargetFieldHelpInfo, ...]
12✔
254

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

288

289
def maybe_cleandoc(doc: str | None) -> str | None:
12✔
290
    return doc and inspect.cleandoc(doc)
2✔
291

292

293
@dataclass(frozen=True)
12✔
294
class RuleInfo:
12✔
295
    """A container for help information for a rule.
296

297
    The `description` is the `desc` provided to the `@rule` decorator, and `documentation` is the
298
    rule's doc string.
299
    """
300

301
    name: str
12✔
302
    description: str | None
12✔
303
    documentation: str | None
12✔
304
    provider: str
12✔
305
    output_type: str
12✔
306
    input_types: tuple[str, ...]
12✔
307
    awaitables: tuple[str, ...]
12✔
308

309
    @classmethod
12✔
310
    def create(cls, rule: TaskRule, provider: str) -> RuleInfo:
12✔
311
        return cls(
2✔
312
            name=rule.canonical_name,
313
            description=rule.desc,
314
            documentation=maybe_cleandoc(rule.func.__doc__),
315
            provider=provider,
316
            input_types=tuple(typ.__name__ for typ in rule.parameters.values()),
317
            awaitables=tuple(str(constraints) for constraints in rule.awaitables),
318
            output_type=rule.output_type.__name__,
319
        )
320

321

322
@dataclass(frozen=True)
12✔
323
class PluginAPITypeInfo:
12✔
324
    """A container for help information for a plugin API type.
325

326
    Plugin API types are used as input parameters and output results for rules.
327
    """
328

329
    name: str
12✔
330
    module: str
12✔
331
    documentation: str | None
12✔
332
    provider: tuple[str, ...]
12✔
333
    is_union: bool
12✔
334
    union_type: str | None
12✔
335
    union_members: tuple[str, ...]
12✔
336
    dependencies: tuple[str, ...]
12✔
337
    dependents: tuple[str, ...]
12✔
338
    returned_by_rules: tuple[str, ...]
12✔
339
    consumed_by_rules: tuple[str, ...]
12✔
340
    used_in_rules: tuple[str, ...]
12✔
341

342
    @property
12✔
343
    def fully_qualified_name(self) -> str:
12✔
344
        return f"{self.module}.{self.name}"
×
345

346
    @staticmethod
12✔
347
    def fully_qualified_name_from_type(t: type):
12✔
348
        return f"{t.__module__}.{t.__qualname__}"
3✔
349

350
    @classmethod
12✔
351
    def create(
12✔
352
        cls, api_type: type, rules: Sequence[Rule | UnionRule], **kwargs
353
    ) -> PluginAPITypeInfo:
354
        union_type: str | None = None
1✔
355
        for rule in filter(cls._member_type_for(api_type), rules):
1✔
356
            # All filtered rules out of `_member_type_for` will be `UnionRule`s.
357
            union_type = cast(UnionRule, rule).union_base.__name__
×
358
            break
×
359

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

362
        return cls(
1✔
363
            name=api_type.__qualname__,
364
            module=api_type.__module__,
365
            documentation=maybe_cleandoc(api_type.__doc__),
366
            is_union=is_union(api_type),
367
            union_type=union_type,
368
            consumed_by_rules=cls._all_rules(cls._rule_consumes(api_type), task_rules),
369
            returned_by_rules=cls._all_rules(cls._rule_returns(api_type), task_rules),
370
            used_in_rules=cls._all_rules(cls._rule_uses(api_type), task_rules),
371
            **kwargs,
372
        )
373

374
    @staticmethod
12✔
375
    def _all_rules(
12✔
376
        satisfies: Callable[[TaskRule], bool], rules: Sequence[TaskRule]
377
    ) -> tuple[str, ...]:
378
        return tuple(sorted(rule.canonical_name for rule in filter(satisfies, rules)))
1✔
379

380
    @staticmethod
12✔
381
    def _rule_consumes(api_type: type) -> Callable[[TaskRule], bool]:
12✔
382
        def satisfies(rule: TaskRule) -> bool:
1✔
383
            return api_type in rule.parameters.values()
1✔
384

385
        return satisfies
1✔
386

387
    @staticmethod
12✔
388
    def _rule_returns(api_type: type) -> Callable[[TaskRule], bool]:
12✔
389
        def satisfies(rule: TaskRule) -> bool:
1✔
390
            return rule.output_type is api_type
1✔
391

392
        return satisfies
1✔
393

394
    @staticmethod
12✔
395
    def _rule_uses(api_type: type) -> Callable[[TaskRule], bool]:
12✔
396
        def satisfies(rule: TaskRule) -> bool:
1✔
397
            return any(
1✔
398
                api_type in (*constraint.input_types, constraint.output_type)
399
                for constraint in rule.awaitables
400
            )
401

402
        return satisfies
1✔
403

404
    @staticmethod
12✔
405
    def _member_type_for(api_type: type) -> Callable[[Rule | UnionRule], bool]:
12✔
406
        def satisfies(rule: Rule | UnionRule) -> bool:
1✔
407
            return isinstance(rule, UnionRule) and rule.union_member is api_type
1✔
408

409
        return satisfies
1✔
410

411
    def merged_with(self, that: PluginAPITypeInfo) -> PluginAPITypeInfo:
12✔
412
        def merge_tuples(l, r):
×
413
            return tuple(sorted({*l, *r}))
×
414

415
        return PluginAPITypeInfo(
×
416
            self.name,
417
            self.module,
418
            self.documentation,
419
            merge_tuples(self.provider, that.provider),
420
            self.is_union,
421
            self.union_type,
422
            merge_tuples(self.union_members, that.union_members),
423
            merge_tuples(self.dependencies, that.dependencies),
424
            merge_tuples(self.dependents, that.dependents),
425
            merge_tuples(self.returned_by_rules, that.returned_by_rules),
426
            merge_tuples(self.consumed_by_rules, that.consumed_by_rules),
427
            merge_tuples(self.used_in_rules, that.used_in_rules),
428
        )
429

430

431
@dataclass(frozen=True)
12✔
432
class BackendHelpInfo:
12✔
433
    name: str
12✔
434
    description: str
12✔
435
    enabled: bool
12✔
436
    provider: str
12✔
437

438

439
@dataclass(frozen=True)
12✔
440
class BuildFileSymbolHelpInfo:
12✔
441
    name: str
12✔
442
    is_target: bool
12✔
443
    signature: str | None
12✔
444
    documentation: str | None
12✔
445

446

447
@dataclass(frozen=True)
12✔
448
class AllHelpInfo:
12✔
449
    """All available help info."""
450

451
    scope_to_help_info: LazyFrozenDict[str, OptionScopeHelpInfo]
12✔
452
    name_to_goal_info: LazyFrozenDict[str, GoalHelpInfo]
12✔
453
    name_to_target_type_info: LazyFrozenDict[str, TargetTypeHelpInfo]
12✔
454
    name_to_rule_info: LazyFrozenDict[str, RuleInfo]
12✔
455
    name_to_api_type_info: LazyFrozenDict[str, PluginAPITypeInfo]
12✔
456
    name_to_backend_help_info: LazyFrozenDict[str, BackendHelpInfo]
12✔
457
    name_to_build_file_info: LazyFrozenDict[str, BuildFileSymbolHelpInfo]
12✔
458
    env_var_to_help_info: LazyFrozenDict[str, OptionHelpInfo]
12✔
459

460
    def non_deprecated_option_scope_help_infos(self):
12✔
461
        for oshi in self.scope_to_help_info.values():
×
462
            if not oshi.is_deprecated_scope():
×
463
                yield oshi
×
464

465
    def asdict(self) -> dict[str, Any]:
12✔
466
        return {
1✔
467
            field: {thing: dataclasses.asdict(info) for thing, info in value.items()}
468
            for field, value in dataclasses.asdict(self).items()
469
        }
470

471

472
ConsumedScopesMapper = Callable[[str], tuple[str, ...]]
12✔
473

474

475
class HelpInfoExtracter:
12✔
476
    """Extracts information useful for displaying help from option registration args."""
477

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

520
            return load
3✔
521

522
        def goal_help_info_loader_for(scope_info: ScopeInfo) -> Callable[[], GoalHelpInfo]:
3✔
523
            def load() -> GoalHelpInfo:
3✔
524
                subsystem_cls = scope_info.subsystem_cls
1✔
525
                assert subsystem_cls is not None
1✔
526
                if build_configuration is not None:
1✔
527
                    provider = cls.get_provider(
1✔
528
                        build_configuration.subsystem_to_providers.get(subsystem_cls),
529
                        subsystem_cls.__module__,
530
                    )
531
                goal_subsystem_cls = cast(type[GoalSubsystem], subsystem_cls)
1✔
532
                return GoalHelpInfo(
1✔
533
                    goal_subsystem_cls.name,
534
                    scope_info.description,
535
                    provider,
536
                    goal_subsystem_cls.activated(union_membership),
537
                    consumed_scopes_mapper(scope_info.scope),
538
                )
539

540
            return load
3✔
541

542
        def target_type_info_for(target_type: type[Target]) -> Callable[[], TargetTypeHelpInfo]:
3✔
543
            def load() -> TargetTypeHelpInfo:
3✔
544
                return TargetTypeHelpInfo.create(
2✔
545
                    target_type,
546
                    union_membership=union_membership,
547
                    provider=cls.get_provider(
548
                        build_configuration
549
                        and build_configuration.target_type_to_providers.get(target_type)
550
                        or None,
551
                        target_type.__module__,
552
                    ),
553
                    get_field_type_provider=lambda field_type: cls.get_provider(
554
                        (
555
                            build_configuration.union_rule_to_providers.get(
556
                                UnionRule(target_type.PluginField, field_type)
557
                            )
558
                            if build_configuration is not None
559
                            else None
560
                        ),
561
                        field_type.__module__,
562
                    ),
563
                )
564

565
            return load
3✔
566

567
        def lazily(value: T) -> Callable[[], T]:
3✔
568
            return lambda: value
3✔
569

570
        known_scope_infos = sorted(options.known_scope_to_info.values(), key=lambda x: x.scope)
3✔
571
        scope_to_help_info = LazyFrozenDict(
3✔
572
            {
573
                scope_info.scope: option_scope_help_info_loader_for(scope_info)
574
                for scope_info in known_scope_infos
575
                if not scope_info.scope.startswith("_")
576
            }
577
        )
578

579
        env_var_to_help_info = LazyFrozenDict(
3✔
580
            {
581
                ohi.env_var: lazily(ohi)
582
                for oshi in scope_to_help_info.values()
583
                for ohi in chain(oshi.basic, oshi.advanced, oshi.deprecated)
584
            }
585
        )
586

587
        name_to_goal_info = LazyFrozenDict(
3✔
588
            {
589
                scope_info.scope: goal_help_info_loader_for(scope_info)
590
                for scope_info in known_scope_infos
591
                if (
592
                    scope_info.is_goal
593
                    and not scope_info.scope.startswith("_")
594
                    # `filter` should be treated as a subsystem for `help`, even though it still
595
                    # works as a goal for backwards compatibility.
596
                    and scope_info.scope != "filter"
597
                )
598
            }
599
        )
600

601
        name_to_target_type_info = LazyFrozenDict(
3✔
602
            {
603
                alias: target_type_info_for(target_type)
604
                for alias, target_type in registered_target_types.aliases_to_types.items()
605
                if (target_type.removal_version is None and alias != target_type.deprecated_alias)
606
            }
607
        )
608

609
        return AllHelpInfo(
3✔
610
            scope_to_help_info=scope_to_help_info,
611
            name_to_goal_info=name_to_goal_info,
612
            name_to_target_type_info=name_to_target_type_info,
613
            name_to_rule_info=cls.get_rule_infos(build_configuration),
614
            name_to_api_type_info=cls.get_api_type_infos(build_configuration, union_membership),
615
            name_to_backend_help_info=cls.get_backend_help_info(options),
616
            name_to_build_file_info=cls.get_build_file_info(build_symbols),
617
            env_var_to_help_info=env_var_to_help_info,
618
        )
619

620
    @staticmethod
12✔
621
    def compute_default(**kwargs) -> Any:
12✔
622
        """Compute the default val for help display for an option registered with these kwargs."""
623
        # If the kwargs already determine a string representation of the default for use in help
624
        # messages, use that.
625
        default_help_repr = kwargs.get("default_help_repr")
3✔
626
        if default_help_repr is not None:
3✔
627
            return str(default_help_repr)  # Should already be a string, but might as well be safe.
3✔
628

629
        default = kwargs.get("default")
3✔
630
        if default is not None:
3✔
631
            return default
3✔
632
        if is_list_option(kwargs):
3✔
633
            return []
1✔
634
        elif is_dict_option(kwargs):
3✔
635
            return {}
1✔
636
        return None
3✔
637

638
    @staticmethod
12✔
639
    def stringify_type(t: type) -> str:
12✔
640
        if t == dict:
3✔
641
            return "{'key1': val1, 'key2': val2, ...}"
3✔
642
        return f"<{t.__name__}>"
3✔
643

644
    @staticmethod
12✔
645
    def compute_metavar(kwargs):
12✔
646
        """Compute the metavar to display in help for an option registered with these kwargs."""
647

648
        stringify = HelpInfoExtracter.stringify_type
3✔
649

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

668
    @staticmethod
12✔
669
    def compute_choices(kwargs) -> tuple[str, ...] | None:
12✔
670
        """Compute the option choices to display."""
671
        typ = kwargs.get("type", [])
3✔
672
        member_type = kwargs.get("member_type", str)
3✔
673
        if typ == list and inspect.isclass(member_type) and issubclass(member_type, Enum):
3✔
674
            return tuple(choice.value for choice in member_type)
1✔
675
        elif inspect.isclass(typ) and issubclass(typ, Enum):
3✔
676
            return tuple(choice.value for choice in typ)
3✔
677
        elif "choices" in kwargs:
3✔
678
            return tuple(str(choice) for choice in kwargs["choices"])
1✔
679
        else:
680
            return None
3✔
681

682
    @staticmethod
12✔
683
    def get_provider(providers: tuple[str, ...] | None, hint: str | None = None) -> str:
12✔
684
        """Get the best match for the provider.
685

686
        To take advantage of provider names being modules, `hint` should be something like the a
687
        subsystem's `__module__` or a rule's `canonical_name`.
688
        """
689
        if not providers:
3✔
690
            return ""
3✔
691
        if not hint:
3✔
692
            # Pick the shortest backend name.
693
            return sorted(providers, key=len)[0]
×
694

695
        # Pick the one closest to `hint`.
696
        # No cutoff and max 1 result guarantees a result with a single element.
697
        [provider] = difflib.get_close_matches(hint, providers, n=1, cutoff=0.0)
3✔
698
        return provider
3✔
699

700
    @staticmethod
12✔
701
    def maybe_cleandoc(doc: str | None) -> str | None:
12✔
702
        return doc and inspect.cleandoc(doc)
×
703

704
    @staticmethod
12✔
705
    def get_module_docstring(filename: str) -> str:
12✔
706
        with open(filename) as fd:
1✔
707
            return ast.get_docstring(ast.parse(fd.read(), filename)) or ""
1✔
708

709
    @classmethod
12✔
710
    def get_rule_infos(
12✔
711
        cls, build_configuration: BuildConfiguration | None
712
    ) -> LazyFrozenDict[str, RuleInfo]:
713
        if build_configuration is None:
3✔
714
            return LazyFrozenDict({})
×
715

716
        def rule_info_loader(rule: TaskRule, provider: str) -> Callable[[], RuleInfo]:
3✔
717
            def load() -> RuleInfo:
3✔
718
                return RuleInfo.create(rule, provider)
2✔
719

720
            return load
3✔
721

722
        return LazyFrozenDict(
3✔
723
            {
724
                rule.canonical_name: rule_info_loader(
725
                    rule, cls.get_provider(providers, rule.canonical_name)
726
                )
727
                for rule, providers in build_configuration.rule_to_providers.items()
728
                if isinstance(rule, TaskRule)
729
            }
730
        )
731

732
    @classmethod
12✔
733
    def get_api_type_infos(
12✔
734
        cls, build_configuration: BuildConfiguration | None, union_membership: UnionMembership
735
    ) -> LazyFrozenDict[str, PluginAPITypeInfo]:
736
        if build_configuration is None:
3✔
737
            return LazyFrozenDict({})
×
738

739
        # The type narrowing achieved with the above `if` does not extend to the created closures of
740
        # the nested functions in this body, so instead we capture it in a new variable.
741
        bc = build_configuration
3✔
742

743
        known_providers = cast(
3✔
744
            "set[str]",
745
            set(
746
                chain.from_iterable(
747
                    chain.from_iterable(cast(dict, providers).values())
748
                    for providers in (
749
                        bc.subsystem_to_providers,
750
                        bc.target_type_to_providers,
751
                        bc.rule_to_providers,
752
                        bc.union_rule_to_providers,
753
                    )
754
                )
755
            ),
756
        )
757

758
        def _find_provider(api_type: type) -> str:
3✔
759
            provider = api_type.__module__
3✔
760
            while provider:
3✔
761
                if provider in known_providers:
3✔
762
                    return provider
×
763
                if "." not in provider:
3✔
764
                    break
3✔
765
                provider = provider.rsplit(".", 1)[0]
3✔
766
            # Unknown provider, depend directly on the type's module.
767
            return api_type.__module__
3✔
768

769
        def _rule_dependencies(rule: TaskRule) -> Iterator[type]:
3✔
770
            yield from rule.parameters.values()
3✔
771
            for constraint in rule.awaitables:
3✔
772
                yield constraint.output_type
3✔
773

774
        def _extract_api_types() -> Iterator[tuple[type, str, tuple[type, ...]]]:
3✔
775
            """Return all possible types we encounter in all known rules with provider and
776
            dependencies."""
777
            for rule, providers in bc.rule_to_providers.items():
3✔
778
                if not isinstance(rule, TaskRule):
3✔
779
                    continue
2✔
780
                provider = cls.get_provider(providers, rule.canonical_name)
3✔
781
                yield rule.output_type, provider, tuple(_rule_dependencies(rule))
3✔
782

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

794
            union_bases: set[type] = set()
3✔
795
            for union_rule, providers in bc.union_rule_to_providers.items():
3✔
796
                provider = cls.get_provider(providers, union_rule.union_member.__module__)
2✔
797
                union_bases.add(union_rule.union_base)
2✔
798
                yield union_rule.union_member, provider, (union_rule.union_base,)
2✔
799

800
            for union_base in union_bases:
3✔
801
                yield union_base, _find_provider(union_base), ()
2✔
802

803
        all_types_with_dependencies = list(_extract_api_types())
3✔
804
        all_types = {api_type for api_type, _, _ in all_types_with_dependencies}
3✔
805
        type_graph: DefaultDict[type, dict[str, tuple[str, ...]]] = defaultdict(dict)
3✔
806

807
        # Calculate type graph.
808
        for api_type in all_types:
3✔
809
            # Collect all providers first, as we need them up-front for the dependencies/dependents.
810
            type_graph[api_type]["providers"] = tuple(
3✔
811
                sorted(
812
                    {
813
                        provider
814
                        for a_type, provider, _ in all_types_with_dependencies
815
                        if provider and a_type is api_type
816
                    }
817
                )
818
            )
819

820
        for api_type in all_types:
3✔
821
            # Resolve type dependencies to providers.
822
            type_graph[api_type]["dependencies"] = tuple(
3✔
823
                sorted(
824
                    set(
825
                        chain.from_iterable(
826
                            type_graph[dependency].setdefault(
827
                                "providers", (_find_provider(dependency),)
828
                            )
829
                            for a_type, _, dependencies in all_types_with_dependencies
830
                            if a_type is api_type
831
                            for dependency in dependencies
832
                        )
833
                    )
834
                    - set(
835
                        # Exclude providers from list of dependencies.
836
                        type_graph[api_type]["providers"]
837
                    )
838
                )
839
            )
840

841
        # Add a dependent on the target type for each dependency.
842
        type_dependents: DefaultDict[type, set[str]] = defaultdict(set)
3✔
843
        for _, provider, dependencies in all_types_with_dependencies:
3✔
844
            if not provider:
3✔
845
                continue
×
846
            for target_type in dependencies:
3✔
847
                type_dependents[target_type].add(provider)
3✔
848
        for api_type, dependents in type_dependents.items():
3✔
849
            type_graph[api_type]["dependents"] = tuple(
3✔
850
                sorted(
851
                    dependents
852
                    - set(
853
                        # Exclude providers from list of dependents.
854
                        type_graph[api_type]["providers"][0]
855
                    )
856
                )
857
            )
858

859
        rules = cast(
3✔
860
            "tuple[Rule | UnionRule]",
861
            tuple(
862
                chain(
863
                    bc.rule_to_providers.keys(),
864
                    bc.union_rule_to_providers.keys(),
865
                )
866
            ),
867
        )
868

869
        def get_api_type_info(api_types: tuple[type, ...]):
3✔
870
            """Gather the info from each of the types and aggregate it.
871

872
            The gathering is the expensive operation, and we can only aggregate once we've gathered.
873
            """
874

875
            def load() -> PluginAPITypeInfo:
3✔
876
                gatherered_infos = [
1✔
877
                    PluginAPITypeInfo.create(
878
                        api_type,
879
                        rules,
880
                        provider=type_graph[api_type]["providers"],
881
                        dependencies=type_graph[api_type]["dependencies"],
882
                        dependents=type_graph[api_type].get("dependents", ()),
883
                        union_members=tuple(
884
                            sorted(member.__qualname__ for member in union_membership.get(api_type))
885
                        ),
886
                    )
887
                    for api_type in api_types
888
                ]
889
                return reduce(lambda x, y: x.merged_with(y), gatherered_infos)
1✔
890

891
            return load
3✔
892

893
        # We want to provide a lazy dict so we don't spend so long doing the info gathering.
894
        # We provide a list of the types here, and the lookup function performs the gather and the aggregation
895
        api_type_name = PluginAPITypeInfo.fully_qualified_name_from_type
3✔
896
        all_types_sorted = sorted(all_types, key=api_type_name)
3✔
897
        infos: dict[str, Callable[[], PluginAPITypeInfo]] = {
3✔
898
            k: get_api_type_info(tuple(v))
899
            for k, v in itertools.groupby(all_types_sorted, key=api_type_name)
900
        }
901

902
        return LazyFrozenDict(infos)
3✔
903

904
    @classmethod
12✔
905
    def get_backend_help_info(cls, options: Options) -> LazyFrozenDict[str, BackendHelpInfo]:
12✔
906
        DiscoveredBackend = namedtuple(
3✔
907
            "DiscoveredBackend", "provider, name, register_py, enabled", defaults=(False,)
908
        )
909

910
        def discover_source_backends(root: Path, is_source_root: bool) -> set[DiscoveredBackend]:
3✔
911
            provider = root.name
3✔
912
            source_root = f"{root}/" if is_source_root else f"{root.parent}/"
3✔
913
            register_pys = root.glob("**/register.py")
3✔
914
            backends = {
3✔
915
                DiscoveredBackend(
916
                    provider,
917
                    str(register_py.parent).replace(source_root, "").replace("/", "."),
918
                    str(register_py),
919
                )
920
                for register_py in register_pys
921
            }
922
            return backends
3✔
923

924
        def discover_plugin_backends(entry_point_name: str) -> frozenset[DiscoveredBackend]:
3✔
925
            def get_dist_file(dist: importlib.metadata.Distribution, file_path: str):
3✔
926
                try:
1✔
927
                    return dist.locate_file(file_path)
1✔
928
                except Exception as e:
×
929
                    raise ValueError(
×
930
                        f"Failed to locate file `{file_path}` in distribution `{dist}`: {e}"
931
                    )
932

933
            backends = frozenset(
3✔
934
                DiscoveredBackend(
935
                    entry_point.dist.name,
936
                    entry_point.module,
937
                    str(
938
                        get_dist_file(
939
                            entry_point.dist, entry_point.module.replace(".", "/") + ".py"
940
                        )
941
                    ),
942
                    True,
943
                )
944
                for entry_point in importlib.metadata.entry_points().select(group=entry_point_name)
945
                if entry_point.dist is not None
946
            )
947
            return backends
3✔
948

949
        global_options = options.for_global_scope()
3✔
950
        builtin_backends = discover_source_backends(
3✔
951
            Path(pants.backend.__file__).parent.parent, is_source_root=False
952
        )
953
        inrepo_backends = chain.from_iterable(
3✔
954
            discover_source_backends(Path(path_entry), is_source_root=True)
955
            for path_entry in global_options.pythonpath
956
        )
957
        plugin_backends = discover_plugin_backends("pantsbuild.plugin")
3✔
958
        enabled_backends = {
3✔
959
            "pants.core",
960
            "pants.backend.project_info",
961
            *global_options.backend_packages,
962
        }
963

964
        def get_backend_help_info_loader(
3✔
965
            discovered_backend: DiscoveredBackend,
966
        ) -> Callable[[], BackendHelpInfo]:
967
            def load() -> BackendHelpInfo:
3✔
968
                return BackendHelpInfo(
1✔
969
                    name=discovered_backend.name,
970
                    description=cls.get_module_docstring(discovered_backend.register_py),
971
                    enabled=(
972
                        discovered_backend.enabled or discovered_backend.name in enabled_backends
973
                    ),
974
                    provider=discovered_backend.provider,
975
                )
976

977
            return load
3✔
978

979
        return LazyFrozenDict(
3✔
980
            {
981
                discovered_backend.name: get_backend_help_info_loader(discovered_backend)
982
                for discovered_backend in sorted(
983
                    chain(builtin_backends, inrepo_backends, plugin_backends)
984
                )
985
            }
986
        )
987

988
    @staticmethod
12✔
989
    def get_build_file_info(
12✔
990
        build_symbols: BuildFileSymbolsInfo,
991
    ) -> LazyFrozenDict[str, BuildFileSymbolHelpInfo]:
992
        def get_build_file_symbol_help_info_loader(
3✔
993
            symbol: BuildFileSymbolInfo,
994
        ) -> Callable[[], BuildFileSymbolHelpInfo]:
995
            def load() -> BuildFileSymbolHelpInfo:
1✔
996
                return BuildFileSymbolHelpInfo(
1✔
997
                    name=symbol.name,
998
                    is_target=isinstance(symbol.value, Registrar),
999
                    signature=symbol.signature,
1000
                    documentation=symbol.help,
1001
                )
1002

1003
            return load
1✔
1004

1005
        return LazyFrozenDict(
3✔
1006
            {
1007
                symbol.name: get_build_file_symbol_help_info_loader(symbol)
1008
                for symbol in build_symbols.info.values()
1009
                # (NB. we don't just check name.startswith("_") because there's symbols like
1010
                # __default__ that should appear in help & docs)
1011
                if not symbol.hide_from_help
1012
            }
1013
        )
1014

1015
    def __init__(self, scope: str):
12✔
1016
        self._scope = scope
3✔
1017
        self._scope_prefix = scope.replace(".", "-")
3✔
1018

1019
    def get_option_scope_help_info(
12✔
1020
        self,
1021
        description: str,
1022
        registrar: OptionRegistrar,
1023
        native_parser: NativeOptionParser,
1024
        is_goal: bool,
1025
        provider: str = "",
1026
        deprecated_scope: str | None = None,
1027
    ) -> OptionScopeHelpInfo:
1028
        """Returns an OptionScopeHelpInfo for the options parsed by the given parser."""
1029

1030
        basic_options = []
3✔
1031
        advanced_options = []
3✔
1032
        deprecated_options = []
3✔
1033
        for option_info in registrar.option_registrations_iter():
3✔
1034
            derivation = native_parser.get_derivation(registrar.scope, option_info)
3✔
1035
            # Massage the derivation structure returned by the NativeOptionParser into an
1036
            # OptionValueHistory as returned by the legacy parser.
1037
            # TODO: Once we get rid of the legacy parser we can probably simplify by
1038
            #  using the native structure directly.
1039
            ranked_values = []
3✔
1040

1041
            # Adding this constant, empty history entry is silly, but it appears in the
1042
            # legacy parser's results as an implementation artifact, and we want to be
1043
            # consistent with its tests until we get rid of it.
1044
            is_list = option_info.kwargs.get("type") == list
3✔
1045
            is_dict = option_info.kwargs.get("type") == dict
3✔
1046
            empty_val: list | dict | None = [] if is_list else {} if is_dict else None
3✔
1047
            empty_details = "" if (is_list or is_dict) else None
3✔
1048
            ranked_values.append(RankedValue(Rank.NONE, empty_val, empty_details))
3✔
1049

1050
            for value, rank, details in derivation:
3✔
1051
                ranked_values.append(RankedValue(rank, value, details or empty_details))
3✔
1052
            history = OptionValueHistory(tuple(ranked_values))
3✔
1053
            ohi = self.get_option_help_info(option_info)
3✔
1054
            ohi = dataclasses.replace(ohi, value_history=history)
3✔
1055
            if ohi.deprecation_active:
3✔
1056
                deprecated_options.append(ohi)
2✔
1057
            elif option_info.kwargs.get("advanced"):
3✔
1058
                advanced_options.append(ohi)
3✔
1059
            else:
1060
                basic_options.append(ohi)
3✔
1061

1062
        return OptionScopeHelpInfo(
3✔
1063
            scope=self._scope,
1064
            description=description,
1065
            provider=provider,
1066
            is_goal=is_goal,
1067
            deprecated_scope=deprecated_scope,
1068
            basic=tuple(basic_options),
1069
            advanced=tuple(advanced_options),
1070
            deprecated=tuple(deprecated_options),
1071
        )
1072

1073
    def get_option_help_info(self, option_info: OptionInfo) -> OptionHelpInfo:
12✔
1074
        """Returns an OptionHelpInfo for the option registered with the given (args, kwargs)."""
1075
        display_args = []
3✔
1076
        scoped_cmd_line_args = []
3✔
1077
        unscoped_cmd_line_args = []
3✔
1078

1079
        for arg in option_info.args:
3✔
1080
            is_short_arg = len(arg) == 2
3✔
1081
            unscoped_cmd_line_args.append(arg)
3✔
1082
            if self._scope_prefix:
3✔
1083
                scoped_arg = f"--{self._scope_prefix}-{arg.lstrip('-')}"
3✔
1084
            else:
1085
                scoped_arg = arg
3✔
1086
            scoped_cmd_line_args.append(scoped_arg)
3✔
1087

1088
            if OptionRegistrar.is_bool(option_info.kwargs):
3✔
1089
                if is_short_arg:
3✔
1090
                    display_args.append(scoped_arg)
1✔
1091
                else:
1092
                    unscoped_cmd_line_args.append(f"--no-{arg[2:]}")
3✔
1093
                    sa_2 = scoped_arg[2:]
3✔
1094
                    scoped_cmd_line_args.append(f"--no-{sa_2}")
3✔
1095
                    display_args.append(f"--[no-]{sa_2}")
3✔
1096
            else:
1097
                metavar = self.compute_metavar(option_info.kwargs)
3✔
1098
                separator = "" if is_short_arg else "="
3✔
1099
                display_args.append(f"{scoped_arg}{separator}{metavar}")
3✔
1100
                if option_info.kwargs.get("passthrough"):
3✔
1101
                    type_str = self.stringify_type(option_info.kwargs.get("member_type", str))
1✔
1102
                    display_args.append(f"... -- [{type_str} [{type_str} [...]]]")
1✔
1103

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

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

1139
        target_field_name = (
3✔
1140
            f"{self._scope_prefix}_{option_field_name_for(option_info.args)}".replace("-", "_")
1141
        )
1142
        environment_aware = option_info.kwargs.get("environment_aware") is True
3✔
1143
        fromfile = option_info.kwargs.get("fromfile", False)
3✔
1144

1145
        ret = OptionHelpInfo(
3✔
1146
            display_args=tuple(display_args),
1147
            comma_separated_display_args=", ".join(display_args),
1148
            scoped_cmd_line_args=tuple(scoped_cmd_line_args),
1149
            unscoped_cmd_line_args=tuple(unscoped_cmd_line_args),
1150
            env_var=env_var,
1151
            config_key=dest,
1152
            target_field_name=target_field_name if environment_aware else None,
1153
            typ=typ,
1154
            default=default,
1155
            help=help_msg,
1156
            deprecation_active=deprecation_active,
1157
            deprecated_message=deprecated_message,
1158
            removal_version=removal_version,
1159
            removal_hint=removal_hint,
1160
            choices=choices,
1161
            comma_separated_choices=None if choices is None else ", ".join(choices),
1162
            value_history=None,
1163
            fromfile=fromfile,
1164
        )
1165
        return ret
3✔
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