File size: 20,428 Bytes
53dbcc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
import inspect
import sys
from collections.abc import Iterable, Sequence
from enum import Enum
from functools import lru_cache
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    ForwardRef,
    Literal,
)

from attrs import define, evolve, field

from cyclopts.annotations import resolve_annotated
from cyclopts.core import _get_root_module_name
from cyclopts.field_info import get_field_infos
from cyclopts.group import Group
from cyclopts.help.inline_text import InlineText
from cyclopts.help.silent import SILENT, SilentRich
from cyclopts.utils import SortHelper, frozen, is_class_and_subclass, resolve_callables

if TYPE_CHECKING:
    from rich.console import RenderableType

    from cyclopts.argument import Argument, ArgumentCollection
    from cyclopts.core import App


@lru_cache(maxsize=16)
def docstring_parse(doc: str | None, format: str):
    """Addon to :func:`docstring_parser.parse` that supports multi-line `short_description`."""
    import docstring_parser

    if not doc:
        return docstring_parser.parse("")

    cleaned_doc = inspect.cleandoc(doc)
    short_description_and_maybe_remainder = cleaned_doc.split("\n\n", 1)

    # Place multi-line summary into a single line.
    # This kind of goes against PEP-0257, but any reasonable CLI command will
    # have either no description, or it will have both a short and long description.
    short = short_description_and_maybe_remainder[0].replace("\n", " ")
    if len(short_description_and_maybe_remainder) == 1:
        cleaned_doc = short
    else:
        cleaned_doc = short + "\n\n" + short_description_and_maybe_remainder[1]

    res = docstring_parser.parse(cleaned_doc)

    # Ensure a short description exists if there's a long description
    assert not res.long_description or res.short_description

    return res


def _text_factory():
    from rich.text import Text

    return Text()


def _description_converter(value: Any | None) -> Any:
    if value is None:
        return _text_factory()
    return value


@frozen(kw_only=True)
class HelpEntry:
    """Container for help table entry data."""

    positive_names: tuple[str, ...] = ()
    """Positive long option names (e.g., "--verbose", "--dry-run")."""

    positive_shorts: tuple[str, ...] = ()
    """Positive short option names (e.g., "-v", "-n")."""

    negative_names: tuple[str, ...] = ()
    """Negative long option names (e.g., "--no-verbose", "--no-dry-run")."""

    negative_shorts: tuple[str, ...] = ()
    """Negative short option names (e.g., "-N"). Rarely used."""

    @property
    def names(self) -> tuple[str, ...]:
        """All long option names (positive + negative). For backward compatibility."""
        return self.positive_names + self.negative_names

    @property
    def shorts(self) -> tuple[str, ...]:
        """All short option names (positive + negative). For backward compatibility."""
        return self.positive_shorts + self.negative_shorts

    @property
    def all_options(self) -> tuple[str, ...]:
        """All options in display order: positive longs, positive shorts, negative longs, negative shorts."""
        return self.positive_names + self.positive_shorts + self.negative_names + self.negative_shorts

    description: Any = None
    """Help text description for this entry.

    Typically a :class:`str` or a :obj:`~rich.console.RenderableType`
    """

    required: bool = False
    """Whether this parameter/command is required."""

    sort_key: Any = None
    """Custom sorting key for ordering entries."""

    type: Any | None = None
    """Type annotation of the parameter."""

    choices: tuple[str, ...] | None = None
    """Available choices for this parameter."""

    env_var: tuple[str, ...] | None = None
    """Environment variable names that can set this parameter."""

    default: str | None = None
    """Default value for this parameter to display. None means no default to show."""

    def copy(self, **kwargs):
        return evolve(self, **kwargs)


@define
class HelpPanel:
    """Data container for help panel information."""

    format: Literal["command", "parameter"]
    """Panel format type."""

    title: "RenderableType"
    """The title text displayed at the top of the help panel."""

    description: Any = field(
        default=None,
        converter=_description_converter,
    )
    """Optional description text displayed below the title.

    Typically a :class:`str` or a :obj:`~rich.console.RenderableType`
    """

    entries: list[HelpEntry] = field(factory=list)
    """List of help entries to display (in order) in the panel."""

    def copy(self, **kwargs):
        return evolve(self, **kwargs)

    def _remove_duplicates(self):
        seen, out = set(), []
        for item in self.entries:
            hashable = (item.names, item.shorts)
            if hashable not in seen:
                seen.add(hashable)
                out.append(item)
        self.entries = out

    def _sort(self):
        """Sort entries in-place."""
        if not self.entries:
            return

        if self.format == "command":
            sorted_sort_helper = SortHelper.sort(
                [
                    SortHelper(
                        entry.sort_key,
                        (
                            entry.names[0].startswith("-") if entry.names else False,
                            entry.names[0] if entry.names else "",
                        ),
                        entry,
                    )
                    for entry in self.entries
                ]
            )
            self.entries = [x.value for x in sorted_sort_helper]
        else:
            raise NotImplementedError


def _is_short(s):
    return not s.startswith("--") and s.startswith("-")


def _categorize_keyword_arguments(argument_collection: "ArgumentCollection") -> tuple[list, list]:
    """Categorize keyword arguments by requirement status for usage string formatting.

    Parameters
    ----------
    argument_collection : ArgumentCollection
        Collection of arguments to categorize.

    Returns
    -------
    tuple[list, list]
        (required_keyword, optional_keyword) where:
        - required_keyword: Required keyword-only parameters
        - optional_keyword: Optional keyword-only parameters and VAR_KEYWORD
    """
    required, optional = [], []

    for argument in argument_collection:
        if not argument.show:
            continue

        if argument.field_info.kind in (argument.field_info.VAR_KEYWORD,):
            optional.append(argument)
        elif argument.field_info.is_keyword_only:
            if argument.required:
                required.append(argument)
            else:
                optional.append(argument)

    return required, optional


def _categorize_positional_arguments(argument_collection: "ArgumentCollection") -> tuple[list, list]:
    """Categorize positional arguments by requirement status for usage string formatting.

    Parameters
    ----------
    argument_collection : ArgumentCollection
        Collection of arguments to categorize.

    Returns
    -------
    tuple[list, list]
        (required_positional, optional_positional) where:
        - required_positional: Required positional and VAR_POSITIONAL parameters
        - optional_positional: Optional positional and VAR_POSITIONAL parameters
    """
    required, optional = [], []

    for argument in argument_collection:
        if not argument.show:
            continue

        if argument.field_info.kind == argument.field_info.VAR_POSITIONAL:
            if argument.required:
                required.append(argument)
            else:
                optional.append(argument)
        elif argument.field_info.is_positional:
            if argument.required:
                required.append(argument)
            else:
                optional.append(argument)

    return required, optional


def format_usage(
    app: "App",
    command_chain: Iterable[str],
):
    from rich.text import Text

    from cyclopts.annotations import get_hint_name

    usage = []

    # If we're at the root level (no command chain), the app has a default_command,
    # and no explicit name was set, derive a better name from sys.argv[0]
    if not command_chain and app.default_command and not app._name:
        # Use the same logic as in App.name property for apps without default_command
        name = Path(sys.argv[0]).name
        if name == "__main__.py":
            name = _get_root_module_name()
        app_name = name
    else:
        app_name = app.name[0]

    usage.append(app_name)
    usage.extend(command_chain)

    for command in command_chain:
        app = app[command]

    # Check for visible non-help/version commands without resolving lazy CommandSpecs.
    help_version_flags = {*app.help_flags, *app.version_flags}
    if any(x not in help_version_flags and app._get_item(x, recurse_meta=True).show for x in app):
        usage.append("COMMAND")

    if app.default_command:
        argument_collection = app.assemble_argument_collection(parse_docstring=False)

        required_keyword_params, optional_keyword_params = _categorize_keyword_arguments(argument_collection)
        required_positional_args, optional_positional_args = _categorize_positional_arguments(argument_collection)

        for argument in required_keyword_params:
            param_name = argument.name
            type_name = get_hint_name(argument.hint).upper()
            usage.append(f"{param_name} {type_name}")

        if optional_keyword_params:
            usage.append("[OPTIONS]")

        for argument in required_positional_args:
            if argument.field_info.kind == argument.field_info.VAR_POSITIONAL:
                arg_name = argument.name.lstrip("-").upper()
                usage.append(f"{arg_name}...")
            else:
                arg_name = argument.name.lstrip("-").upper()
                usage.append(arg_name)

        if optional_positional_args:
            has_var_positional = any(
                arg.field_info.kind == arg.field_info.VAR_POSITIONAL for arg in optional_positional_args
            )
            if has_var_positional:
                usage.append("[ARGS...]")
            else:
                usage.append("[ARGS]")

    return Text(" ".join(usage) + "\n", style="bold")


def _smart_join(strings: Sequence[str]) -> str:
    """Joins strings with a space, unless the previous string ended in a newline."""
    if not strings:
        return ""

    result = [strings[0]]
    for s in strings[1:]:
        if result[-1].endswith("\n"):
            result.append(s)
        else:
            result.append(" " + s)

    return "".join(result)


def format_doc(app: "App", format: str) -> InlineText | SilentRich:
    raw_doc_string = app.help

    if not raw_doc_string:
        return SILENT

    parsed = docstring_parse(raw_doc_string, format)

    components: list[str] = []
    if parsed.short_description:
        components.append(parsed.short_description + "\n")

    if parsed.long_description:
        if parsed.short_description:
            components.append("\n")
        components.append(parsed.long_description + "\n")
    return InlineText.from_format(_smart_join(components), format=format, force_empty_end=True)


def _is_dynamic_structured_dict(argument: "Argument") -> bool:
    """True if ``argument`` is ``dict[str, StructuredType]`` eligible for help expansion.

    Covers pydantic, dataclass, attrs, TypedDict, NamedTuple via the shared
    ``get_field_infos`` dispatcher.  Uses the same indicators as the parser's
    dict branch in ``Argument.__attrs_post_init__``: ``_accepts_keywords`` is
    set, ``_lookup`` is empty (no pre-built children — keys are dynamic), and
    ``_default`` is the value type with structured fields.

    Also matches when ``_default`` is a string/``ForwardRef`` — an unresolved
    self-reference from something like ``dict[str, "Node"]``.  We can't walk
    into it, but we treat it as assumed-structured so the expansion still
    renders a ``.{NAME}`` layer before terminating.
    """
    default = argument._default
    if not (argument._accepts_keywords and not argument._lookup and default is not None):
        return False
    if isinstance(default, (str, ForwardRef)):
        return True
    try:
        return bool(get_field_infos(default))
    except Exception:
        return False


def _expand_structured_dict_for_help(
    argument: "Argument",
    format: str,
    *,
    seen: frozenset[int] = frozenset(),
) -> Iterable[HelpEntry]:
    """Yield help entries for every leaf field of a ``dict[str, StructuredType]``.

    Reuses :meth:`ArgumentCollection._from_type_preview` so synthesized entries
    carry the full metadata (choices, defaults, env_var, required propagation,
    ``Parameter.help`` precedence, ``name_transform``) that the normal
    per-argument path produces.
    """
    # NOTE: help output uses cyclopts' name_transform (e.g. ``my_field`` →
    # ``--models.{NAME}.my-field``).  The parser currently only accepts the raw
    # snake_case form for dict-nested paths; harmonizing the two is a separate
    # follow-up (touches ``_argument.py`` token routing).
    from cyclopts.argument import ArgumentCollection
    from cyclopts.field_info import FieldInfo
    from cyclopts.parameter import Parameter

    value_type = argument._default

    negatives = set(argument.negatives)
    outer_long_names = tuple(o for o in argument.names if o not in negatives and not _is_short(o))

    is_unresolvable = isinstance(value_type, (str, ForwardRef))
    is_cycle = id(value_type) in seen

    if is_cycle or is_unresolvable or not outer_long_names:
        # Cycle or unresolved forward-ref — stop expanding, but still indicate
        # the next level is another ``{NAME}`` layer by appending ``.{{NAME}}``
        # to the names.
        base = _make_help_entry(argument, format)
        if outer_long_names:
            suffixed_names = tuple(f"{n}.{{NAME}}" for n in base.positive_names)
            yield evolve(base, positive_names=suffixed_names)
        else:
            yield base
        return

    new_seen = seen | {id(value_type)}
    synthetic = FieldInfo(
        names=("_preview",),
        kind=FieldInfo.KEYWORD_ONLY,
        annotation=value_type,
        default=FieldInfo.empty,
        required=argument.required,
    )
    for outer in outer_long_names:
        preview = ArgumentCollection._from_type(
            synthetic,
            (),
            Parameter(name=(f"{outer}.{{NAME}}",)),
            group_lookup={},
            group_arguments=Group.create_default_arguments(),
            group_parameters=Group.create_default_parameters(),
            _resolve_groups=False,
        )
        for leaf in preview.filter_by(show=True):
            if _is_dynamic_structured_dict(leaf):
                yield from _expand_structured_dict_for_help(leaf, format, seen=new_seen)
            else:
                yield _make_help_entry(leaf, format)


def _make_help_entry(argument: "Argument", format: str) -> HelpEntry:
    """Build a single ``HelpEntry`` for one ``Argument``.

    Extracted from ``create_parameter_help_panel`` so it can also be applied
    to synthetic preview arguments (see ``_expand_structured_dict_for_help``).
    """
    assert argument.parameter.name_transform

    options = list(argument.names)

    seen: set[str] = set()
    options = [x for x in options if x not in seen and not seen.add(x)]

    if argument.index is not None:
        label_source = next((o for o in options if o.startswith("--")), options[0])
        arg_name = label_source.lstrip("-").upper()
        if arg_name != options[0]:
            options = [arg_name, *options]

    negatives = set(argument.negatives)
    positive_names = [o for o in options if o not in negatives and not _is_short(o)]
    positive_shorts = [o for o in options if o not in negatives and _is_short(o)]
    negative_names = [o for o in options if o in negatives and not _is_short(o)]
    negative_shorts = [o for o in options if o in negatives and _is_short(o)]

    help_description = InlineText.from_format(argument.parameter.help, format=format)

    choices = argument.get_choices()

    env_var = None
    if argument.parameter.show_env_var and argument.parameter.env_var:
        env_var = tuple(argument.parameter.env_var)

    default = None
    if argument.show_default:
        default_val = argument.field_info.default
        if is_class_and_subclass(argument.hint, Enum):
            default = argument.parameter.name_transform(default_val.name)
        elif isinstance(default_val, (list, tuple, set, frozenset)):
            formatted_items = []
            for item in default_val:
                if isinstance(item, Enum):
                    formatted_items.append(argument.parameter.name_transform(item.name))
                elif isinstance(item, str):
                    formatted_items.append(f"'{item}'")
                else:
                    formatted_items.append(str(item))
            if isinstance(default_val, tuple):
                if len(formatted_items) == 1:
                    default = "(" + formatted_items[0] + ",)"
                else:
                    default = "(" + ", ".join(formatted_items) + ")"
            elif isinstance(default_val, list):
                default = "[" + ", ".join(formatted_items) + "]"
            else:
                default = "{" + ", ".join(formatted_items) + "}"
        elif default_val == "":
            default = '""'
        else:
            default = str(default_val)
        if callable(argument.show_default):
            default = argument.show_default(default_val)

    return HelpEntry(
        positive_names=tuple(positive_names),
        positive_shorts=tuple(positive_shorts),
        negative_names=tuple(negative_names),
        negative_shorts=tuple(negative_shorts),
        description=help_description,
        required=argument.required,
        type=resolve_annotated(argument.field_info.annotation),
        choices=choices,
        env_var=env_var,
        default=default,
    )


def create_parameter_help_panel(
    group: "Group",
    argument_collection: "ArgumentCollection",
    format: str,
) -> HelpPanel:
    from rich.text import Text

    kwargs = {
        "format": "parameter",
        "title": group.name,
        "description": InlineText.from_format(group.help, format=format, force_empty_end=True)
        if group.help
        else Text(),
    }

    help_panel = HelpPanel(**kwargs)

    entries_positional, entries_kw = [], []
    for argument in argument_collection.filter_by(show=True):
        if _is_dynamic_structured_dict(argument):
            entries_kw.extend(_expand_structured_dict_for_help(argument, format))
            continue
        entry = _make_help_entry(argument, format)
        if argument.field_info.is_positional:
            entries_positional.append(entry)
        else:
            entries_kw.append(entry)

    help_panel.entries.extend(entries_positional)
    help_panel.entries.extend(entries_kw)

    return help_panel


def format_command_entries(apps_with_names: Iterable, format: str) -> list[HelpEntry]:
    """Format command entries for help display.

    Parameters
    ----------
    apps_with_names : Iterable[RegisteredCommand]
        Iterable of RegisteredCommand tuples.
    format : str
        Help text format.

    Returns
    -------
    list[HelpEntry]
        List of formatted help entries.
    """
    entries = []
    for registered_command in apps_with_names:
        app = registered_command.app
        if not app.show:
            continue
        names = registered_command.names
        # Commands don't have negative variants, so all names are "positive"
        short_names, long_names = [], []
        for name in names:
            short_names.append(name) if _is_short(name) else long_names.append(name)

        sort_key = resolve_callables(app.sort_key, app)

        entry = HelpEntry(
            positive_names=tuple(long_names),
            positive_shorts=tuple(short_names),
            description=InlineText.from_format(docstring_parse(app.help, format).short_description, format=format),
            sort_key=sort_key,
        )
        if entry not in entries:
            entries.append(entry)
    return entries