typer.rich_utils

  1# Extracted and modified from https://github.com/ewels/rich-click
  2
  3import inspect
  4import io
  5import sys
  6from collections import defaultdict
  7from gettext import gettext as _
  8from os import getenv
  9from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Union
 10
 11import click
 12from rich import box
 13from rich.align import Align
 14from rich.columns import Columns
 15from rich.console import Console, RenderableType, group
 16from rich.emoji import Emoji
 17from rich.highlighter import RegexHighlighter
 18from rich.markdown import Markdown
 19from rich.padding import Padding
 20from rich.panel import Panel
 21from rich.table import Table
 22from rich.text import Text
 23from rich.theme import Theme
 24
 25if sys.version_info >= (3, 9):
 26    from typing import Literal
 27else:
 28    from typing_extensions import Literal
 29
 30# Default styles
 31STYLE_OPTION = "bold cyan"
 32STYLE_SWITCH = "bold green"
 33STYLE_NEGATIVE_OPTION = "bold magenta"
 34STYLE_NEGATIVE_SWITCH = "bold red"
 35STYLE_METAVAR = "bold yellow"
 36STYLE_METAVAR_SEPARATOR = "dim"
 37STYLE_USAGE = "yellow"
 38STYLE_USAGE_COMMAND = "bold"
 39STYLE_DEPRECATED = "red"
 40STYLE_DEPRECATED_COMMAND = "dim"
 41STYLE_HELPTEXT_FIRST_LINE = ""
 42STYLE_HELPTEXT = "dim"
 43STYLE_OPTION_HELP = ""
 44STYLE_OPTION_DEFAULT = "dim"
 45STYLE_OPTION_ENVVAR = "dim yellow"
 46STYLE_REQUIRED_SHORT = "red"
 47STYLE_REQUIRED_LONG = "dim red"
 48STYLE_OPTIONS_PANEL_BORDER = "dim"
 49ALIGN_OPTIONS_PANEL: Literal["left", "center", "right"] = "left"
 50STYLE_OPTIONS_TABLE_SHOW_LINES = False
 51STYLE_OPTIONS_TABLE_LEADING = 0
 52STYLE_OPTIONS_TABLE_PAD_EDGE = False
 53STYLE_OPTIONS_TABLE_PADDING = (0, 1)
 54STYLE_OPTIONS_TABLE_BOX = ""
 55STYLE_OPTIONS_TABLE_ROW_STYLES = None
 56STYLE_OPTIONS_TABLE_BORDER_STYLE = None
 57STYLE_COMMANDS_PANEL_BORDER = "dim"
 58ALIGN_COMMANDS_PANEL: Literal["left", "center", "right"] = "left"
 59STYLE_COMMANDS_TABLE_SHOW_LINES = False
 60STYLE_COMMANDS_TABLE_LEADING = 0
 61STYLE_COMMANDS_TABLE_PAD_EDGE = False
 62STYLE_COMMANDS_TABLE_PADDING = (0, 1)
 63STYLE_COMMANDS_TABLE_BOX = ""
 64STYLE_COMMANDS_TABLE_ROW_STYLES = None
 65STYLE_COMMANDS_TABLE_BORDER_STYLE = None
 66STYLE_COMMANDS_TABLE_FIRST_COLUMN = "bold cyan"
 67STYLE_ERRORS_PANEL_BORDER = "red"
 68ALIGN_ERRORS_PANEL: Literal["left", "center", "right"] = "left"
 69STYLE_ERRORS_SUGGESTION = "dim"
 70STYLE_ABORTED = "red"
 71_TERMINAL_WIDTH = getenv("TERMINAL_WIDTH")
 72MAX_WIDTH = int(_TERMINAL_WIDTH) if _TERMINAL_WIDTH else None
 73COLOR_SYSTEM: Optional[Literal["auto", "standard", "256", "truecolor", "windows"]] = (
 74    "auto"  # Set to None to disable colors
 75)
 76_TYPER_FORCE_DISABLE_TERMINAL = getenv("_TYPER_FORCE_DISABLE_TERMINAL")
 77FORCE_TERMINAL = (
 78    True
 79    if getenv("GITHUB_ACTIONS") or getenv("FORCE_COLOR") or getenv("PY_COLORS")
 80    else None
 81)
 82if _TYPER_FORCE_DISABLE_TERMINAL:
 83    FORCE_TERMINAL = False
 84
 85# Fixed strings
 86DEPRECATED_STRING = _("(deprecated) ")
 87DEFAULT_STRING = _("[default: {}]")
 88ENVVAR_STRING = _("[env var: {}]")
 89REQUIRED_SHORT_STRING = "*"
 90REQUIRED_LONG_STRING = _("[required]")
 91RANGE_STRING = " [{}]"
 92ARGUMENTS_PANEL_TITLE = _("Arguments")
 93OPTIONS_PANEL_TITLE = _("Options")
 94COMMANDS_PANEL_TITLE = _("Commands")
 95ERRORS_PANEL_TITLE = _("Error")
 96ABORTED_TEXT = _("Aborted.")
 97RICH_HELP = _("Try [blue]'{command_path} {help_option}'[/] for help.")
 98
 99MARKUP_MODE_MARKDOWN = "markdown"
100MARKUP_MODE_RICH = "rich"
101_RICH_HELP_PANEL_NAME = "rich_help_panel"
102
103MarkupMode = Literal["markdown", "rich", None]
104
105
106# Rich regex highlighter
107class OptionHighlighter(RegexHighlighter):
108    """Highlights our special options."""
109
110    highlights = [
111        r"(^|\W)(?P<switch>\-\w+)(?![a-zA-Z0-9])",
112        r"(^|\W)(?P<option>\-\-[\w\-]+)(?![a-zA-Z0-9])",
113        r"(?P<metavar>\<[^\>]+\>)",
114        r"(?P<usage>Usage: )",
115    ]
116
117
118class NegativeOptionHighlighter(RegexHighlighter):
119    highlights = [
120        r"(^|\W)(?P<negative_switch>\-\w+)(?![a-zA-Z0-9])",
121        r"(^|\W)(?P<negative_option>\-\-[\w\-]+)(?![a-zA-Z0-9])",
122    ]
123
124
125highlighter = OptionHighlighter()
126negative_highlighter = NegativeOptionHighlighter()
127
128
129def _get_rich_console(stderr: bool = False) -> Console:
130    return Console(
131        theme=Theme(
132            {
133                "option": STYLE_OPTION,
134                "switch": STYLE_SWITCH,
135                "negative_option": STYLE_NEGATIVE_OPTION,
136                "negative_switch": STYLE_NEGATIVE_SWITCH,
137                "metavar": STYLE_METAVAR,
138                "metavar_sep": STYLE_METAVAR_SEPARATOR,
139                "usage": STYLE_USAGE,
140            },
141        ),
142        highlighter=highlighter,
143        color_system=COLOR_SYSTEM,
144        force_terminal=FORCE_TERMINAL,
145        width=MAX_WIDTH,
146        stderr=stderr,
147    )
148
149
150def _make_rich_text(
151    *, text: str, style: str = "", markup_mode: MarkupMode
152) -> Union[Markdown, Text]:
153    """Take a string, remove indentations, and return styled text.
154
155    By default, the text is not parsed for any special formatting.
156    If `markup_mode` is `"rich"`, the text is parsed for Rich markup strings.
157    If `markup_mode` is `"markdown"`, parse as Markdown.
158    """
159    # Remove indentations from input text
160    text = inspect.cleandoc(text)
161    if markup_mode == MARKUP_MODE_MARKDOWN:
162        text = Emoji.replace(text)
163        return Markdown(text, style=style)
164    if markup_mode == MARKUP_MODE_RICH:
165        return highlighter(Text.from_markup(text, style=style))
166    else:
167        return highlighter(Text(text, style=style))
168
169
170@group()
171def _get_help_text(
172    *,
173    obj: Union[click.Command, click.Group],
174    markup_mode: MarkupMode,
175) -> Iterable[Union[Markdown, Text]]:
176    """Build primary help text for a click command or group.
177
178    Returns the prose help text for a command or group, rendered either as a
179    Rich Text object or as Markdown.
180    If the command is marked as deprecated, the deprecated string will be prepended.
181    """
182    # Prepend deprecated status
183    if obj.deprecated:
184        yield Text(DEPRECATED_STRING, style=STYLE_DEPRECATED)
185
186    # Fetch and dedent the help text
187    help_text = inspect.cleandoc(obj.help or "")
188
189    # Trim off anything that comes after \f on its own line
190    help_text = help_text.partition("\f")[0]
191
192    # Get the first paragraph
193    first_line = help_text.split("\n\n")[0]
194    # Remove single linebreaks
195    if markup_mode != MARKUP_MODE_MARKDOWN and not first_line.startswith("\b"):
196        first_line = first_line.replace("\n", " ")
197    yield _make_rich_text(
198        text=first_line.strip(),
199        style=STYLE_HELPTEXT_FIRST_LINE,
200        markup_mode=markup_mode,
201    )
202
203    # Add a newline inbetween the header and the remaining paragraphs
204    yield Text("")
205
206    # Get remaining lines, remove single line breaks and format as dim
207    remaining_paragraphs = help_text.split("\n\n")[1:]
208    if remaining_paragraphs:
209        if markup_mode != MARKUP_MODE_RICH:
210            # Remove single linebreaks
211            remaining_paragraphs = [
212                x.replace("\n", " ").strip()
213                if not x.startswith("\b")
214                else "{}\n".format(x.strip("\b\n"))
215                for x in remaining_paragraphs
216            ]
217            # Join back together
218            remaining_lines = "\n".join(remaining_paragraphs)
219        else:
220            # Join with double linebreaks if markdown
221            remaining_lines = "\n\n".join(remaining_paragraphs)
222
223        yield _make_rich_text(
224            text=remaining_lines,
225            style=STYLE_HELPTEXT,
226            markup_mode=markup_mode,
227        )
228
229
230def _get_parameter_help(
231    *,
232    param: Union[click.Option, click.Argument, click.Parameter],
233    ctx: click.Context,
234    markup_mode: MarkupMode,
235) -> Columns:
236    """Build primary help text for a click option or argument.
237
238    Returns the prose help text for an option or argument, rendered either
239    as a Rich Text object or as Markdown.
240    Additional elements are appended to show the default and required status if
241    applicable.
242    """
243    # import here to avoid cyclic imports
244    from .core import TyperArgument, TyperOption
245
246    items: List[Union[Text, Markdown]] = []
247
248    # Get the environment variable first
249
250    envvar = getattr(param, "envvar", None)
251    var_str = ""
252    # https://github.com/pallets/click/blob/0aec1168ac591e159baf6f61026d6ae322c53aaf/src/click/core.py#L2720-L2726
253    if envvar is None:
254        if (
255            getattr(param, "allow_from_autoenv", None)
256            and getattr(ctx, "auto_envvar_prefix", None) is not None
257            and param.name is not None
258        ):
259            envvar = f"{ctx.auto_envvar_prefix}_{param.name.upper()}"
260    if envvar is not None:
261        var_str = (
262            envvar if isinstance(envvar, str) else ", ".join(str(d) for d in envvar)
263        )
264
265    # Main help text
266    help_value: Union[str, None] = getattr(param, "help", None)
267    if help_value:
268        paragraphs = help_value.split("\n\n")
269        # Remove single linebreaks
270        if markup_mode != MARKUP_MODE_MARKDOWN:
271            paragraphs = [
272                x.replace("\n", " ").strip()
273                if not x.startswith("\b")
274                else "{}\n".format(x.strip("\b\n"))
275                for x in paragraphs
276            ]
277        items.append(
278            _make_rich_text(
279                text="\n".join(paragraphs).strip(),
280                style=STYLE_OPTION_HELP,
281                markup_mode=markup_mode,
282            )
283        )
284
285    # Environment variable AFTER help text
286    if envvar and getattr(param, "show_envvar", None):
287        items.append(Text(ENVVAR_STRING.format(var_str), style=STYLE_OPTION_ENVVAR))
288
289    # Default value
290    # This uses Typer's specific param._get_default_string
291    if isinstance(param, (TyperOption, TyperArgument)):
292        if param.show_default:
293            show_default_is_str = isinstance(param.show_default, str)
294            default_value = param._extract_default_help_str(ctx=ctx)
295            default_str = param._get_default_string(
296                ctx=ctx,
297                show_default_is_str=show_default_is_str,
298                default_value=default_value,
299            )
300            if default_str:
301                items.append(
302                    Text(
303                        DEFAULT_STRING.format(default_str),
304                        style=STYLE_OPTION_DEFAULT,
305                    )
306                )
307
308    # Required?
309    if param.required:
310        items.append(Text(REQUIRED_LONG_STRING, style=STYLE_REQUIRED_LONG))
311
312    # Use Columns - this allows us to group different renderable types
313    # (Text, Markdown) onto a single line.
314    return Columns(items)
315
316
317def _make_command_help(
318    *,
319    help_text: str,
320    markup_mode: MarkupMode,
321) -> Union[Text, Markdown]:
322    """Build cli help text for a click group command.
323
324    That is, when calling help on groups with multiple subcommands
325    (not the main help text when calling the subcommand help).
326
327    Returns the first paragraph of help text for a command, rendered either as a
328    Rich Text object or as Markdown.
329    Ignores single newlines as paragraph markers, looks for double only.
330    """
331    paragraphs = inspect.cleandoc(help_text).split("\n\n")
332    # Remove single linebreaks
333    if markup_mode != MARKUP_MODE_RICH and not paragraphs[0].startswith("\b"):
334        paragraphs[0] = paragraphs[0].replace("\n", " ")
335    elif paragraphs[0].startswith("\b"):
336        paragraphs[0] = paragraphs[0].replace("\b\n", "")
337    return _make_rich_text(
338        text=paragraphs[0].strip(),
339        style=STYLE_OPTION_HELP,
340        markup_mode=markup_mode,
341    )
342
343
344def _print_options_panel(
345    *,
346    name: str,
347    params: Union[List[click.Option], List[click.Argument]],
348    ctx: click.Context,
349    markup_mode: MarkupMode,
350    console: Console,
351) -> None:
352    options_rows: List[List[RenderableType]] = []
353    required_rows: List[Union[str, Text]] = []
354    for param in params:
355        # Short and long form
356        opt_long_strs = []
357        opt_short_strs = []
358        secondary_opt_long_strs = []
359        secondary_opt_short_strs = []
360        for opt_str in param.opts:
361            if "--" in opt_str:
362                opt_long_strs.append(opt_str)
363            else:
364                opt_short_strs.append(opt_str)
365        for opt_str in param.secondary_opts:
366            if "--" in opt_str:
367                secondary_opt_long_strs.append(opt_str)
368            else:
369                secondary_opt_short_strs.append(opt_str)
370
371        # Column for a metavar, if we have one
372        metavar = Text(style=STYLE_METAVAR, overflow="fold")
373        # TODO: when deprecating Click < 8.2, make ctx required
374        signature = inspect.signature(param.make_metavar)
375        if "ctx" in signature.parameters:
376            metavar_str = param.make_metavar(ctx=ctx)
377        else:
378            # Click < 8.2
379            metavar_str = param.make_metavar()  # type: ignore[call-arg]
380
381        # Do it ourselves if this is a positional argument
382        if (
383            isinstance(param, click.Argument)
384            and param.name
385            and metavar_str == param.name.upper()
386        ):
387            metavar_str = param.type.name.upper()
388
389        # Skip booleans and choices (handled above)
390        if metavar_str != "BOOLEAN":
391            metavar.append(metavar_str)
392
393        # Range - from
394        # https://github.com/pallets/click/blob/c63c70dabd3f86ca68678b4f00951f78f52d0270/src/click/core.py#L2698-L2706  # noqa: E501
395        # skip count with default range type
396        if (
397            isinstance(param.type, click.types._NumberRangeBase)
398            and isinstance(param, click.Option)
399            and not (param.count and param.type.min == 0 and param.type.max is None)
400        ):
401            range_str = param.type._describe_range()
402            if range_str:
403                metavar.append(RANGE_STRING.format(range_str))
404
405        # Required asterisk
406        required: Union[str, Text] = ""
407        if param.required:
408            required = Text(REQUIRED_SHORT_STRING, style=STYLE_REQUIRED_SHORT)
409
410        # Highlighter to make [ | ] and <> dim
411        class MetavarHighlighter(RegexHighlighter):
412            highlights = [
413                r"^(?P<metavar_sep>(\[|<))",
414                r"(?P<metavar_sep>\|)",
415                r"(?P<metavar_sep>(\]|>)$)",
416            ]
417
418        metavar_highlighter = MetavarHighlighter()
419
420        required_rows.append(required)
421        options_rows.append(
422            [
423                highlighter(",".join(opt_long_strs)),
424                highlighter(",".join(opt_short_strs)),
425                negative_highlighter(",".join(secondary_opt_long_strs)),
426                negative_highlighter(",".join(secondary_opt_short_strs)),
427                metavar_highlighter(metavar),
428                _get_parameter_help(
429                    param=param,
430                    ctx=ctx,
431                    markup_mode=markup_mode,
432                ),
433            ]
434        )
435    rows_with_required: List[List[RenderableType]] = []
436    if any(required_rows):
437        for required, row in zip(required_rows, options_rows):
438            rows_with_required.append([required, *row])
439    else:
440        rows_with_required = options_rows
441    if options_rows:
442        t_styles: Dict[str, Any] = {
443            "show_lines": STYLE_OPTIONS_TABLE_SHOW_LINES,
444            "leading": STYLE_OPTIONS_TABLE_LEADING,
445            "box": STYLE_OPTIONS_TABLE_BOX,
446            "border_style": STYLE_OPTIONS_TABLE_BORDER_STYLE,
447            "row_styles": STYLE_OPTIONS_TABLE_ROW_STYLES,
448            "pad_edge": STYLE_OPTIONS_TABLE_PAD_EDGE,
449            "padding": STYLE_OPTIONS_TABLE_PADDING,
450        }
451        box_style = getattr(box, t_styles.pop("box"), None)
452
453        options_table = Table(
454            highlight=True,
455            show_header=False,
456            expand=True,
457            box=box_style,
458            **t_styles,
459        )
460        for row in rows_with_required:
461            options_table.add_row(*row)
462        console.print(
463            Panel(
464                options_table,
465                border_style=STYLE_OPTIONS_PANEL_BORDER,
466                title=name,
467                title_align=ALIGN_OPTIONS_PANEL,
468            )
469        )
470
471
472def _print_commands_panel(
473    *,
474    name: str,
475    commands: List[click.Command],
476    markup_mode: MarkupMode,
477    console: Console,
478    cmd_len: int,
479) -> None:
480    t_styles: Dict[str, Any] = {
481        "show_lines": STYLE_COMMANDS_TABLE_SHOW_LINES,
482        "leading": STYLE_COMMANDS_TABLE_LEADING,
483        "box": STYLE_COMMANDS_TABLE_BOX,
484        "border_style": STYLE_COMMANDS_TABLE_BORDER_STYLE,
485        "row_styles": STYLE_COMMANDS_TABLE_ROW_STYLES,
486        "pad_edge": STYLE_COMMANDS_TABLE_PAD_EDGE,
487        "padding": STYLE_COMMANDS_TABLE_PADDING,
488    }
489    box_style = getattr(box, t_styles.pop("box"), None)
490
491    commands_table = Table(
492        highlight=False,
493        show_header=False,
494        expand=True,
495        box=box_style,
496        **t_styles,
497    )
498    # Define formatting in first column, as commands don't match highlighter
499    # regex
500    commands_table.add_column(
501        style=STYLE_COMMANDS_TABLE_FIRST_COLUMN,
502        no_wrap=True,
503        width=cmd_len,
504    )
505
506    # A big ratio makes the description column be greedy and take all the space
507    # available instead of allowing the command column to grow and misalign with
508    # other panels.
509    commands_table.add_column("Description", justify="left", no_wrap=False, ratio=10)
510    rows: List[List[Union[RenderableType, None]]] = []
511    deprecated_rows: List[Union[RenderableType, None]] = []
512    for command in commands:
513        helptext = command.short_help or command.help or ""
514        command_name = command.name or ""
515        if command.deprecated:
516            command_name_text = Text(f"{command_name}", style=STYLE_DEPRECATED_COMMAND)
517            deprecated_rows.append(Text(DEPRECATED_STRING, style=STYLE_DEPRECATED))
518        else:
519            command_name_text = Text(command_name)
520            deprecated_rows.append(None)
521        rows.append(
522            [
523                command_name_text,
524                _make_command_help(
525                    help_text=helptext,
526                    markup_mode=markup_mode,
527                ),
528            ]
529        )
530    rows_with_deprecated = rows
531    if any(deprecated_rows):
532        rows_with_deprecated = []
533        for row, deprecated_text in zip(rows, deprecated_rows):
534            rows_with_deprecated.append([*row, deprecated_text])
535    for row in rows_with_deprecated:
536        commands_table.add_row(*row)
537    if commands_table.row_count:
538        console.print(
539            Panel(
540                commands_table,
541                border_style=STYLE_COMMANDS_PANEL_BORDER,
542                title=name,
543                title_align=ALIGN_COMMANDS_PANEL,
544            )
545        )
546
547
548def rich_format_help(
549    *,
550    obj: Union[click.Command, click.Group],
551    ctx: click.Context,
552    markup_mode: MarkupMode,
553) -> None:
554    """Print nicely formatted help text using rich.
555
556    Based on original code from rich-cli, by @willmcgugan.
557    https://github.com/Textualize/rich-cli/blob/8a2767c7a340715fc6fbf4930ace717b9b2fc5e5/src/rich_cli/__main__.py#L162-L236
558
559    Replacement for the click function format_help().
560    Takes a command or group and builds the help text output.
561    """
562    console = _get_rich_console()
563
564    # Print usage
565    console.print(
566        Padding(highlighter(obj.get_usage(ctx)), 1), style=STYLE_USAGE_COMMAND
567    )
568
569    # Print command / group help if we have some
570    if obj.help:
571        # Print with some padding
572        console.print(
573            Padding(
574                Align(
575                    _get_help_text(
576                        obj=obj,
577                        markup_mode=markup_mode,
578                    ),
579                    pad=False,
580                ),
581                (0, 1, 1, 1),
582            )
583        )
584    panel_to_arguments: DefaultDict[str, List[click.Argument]] = defaultdict(list)
585    panel_to_options: DefaultDict[str, List[click.Option]] = defaultdict(list)
586    for param in obj.get_params(ctx):
587        # Skip if option is hidden
588        if getattr(param, "hidden", False):
589            continue
590        if isinstance(param, click.Argument):
591            panel_name = (
592                getattr(param, _RICH_HELP_PANEL_NAME, None) or ARGUMENTS_PANEL_TITLE
593            )
594            panel_to_arguments[panel_name].append(param)
595        elif isinstance(param, click.Option):
596            panel_name = (
597                getattr(param, _RICH_HELP_PANEL_NAME, None) or OPTIONS_PANEL_TITLE
598            )
599            panel_to_options[panel_name].append(param)
600    default_arguments = panel_to_arguments.get(ARGUMENTS_PANEL_TITLE, [])
601    _print_options_panel(
602        name=ARGUMENTS_PANEL_TITLE,
603        params=default_arguments,
604        ctx=ctx,
605        markup_mode=markup_mode,
606        console=console,
607    )
608    for panel_name, arguments in panel_to_arguments.items():
609        if panel_name == ARGUMENTS_PANEL_TITLE:
610            # Already printed above
611            continue
612        _print_options_panel(
613            name=panel_name,
614            params=arguments,
615            ctx=ctx,
616            markup_mode=markup_mode,
617            console=console,
618        )
619    default_options = panel_to_options.get(OPTIONS_PANEL_TITLE, [])
620    _print_options_panel(
621        name=OPTIONS_PANEL_TITLE,
622        params=default_options,
623        ctx=ctx,
624        markup_mode=markup_mode,
625        console=console,
626    )
627    for panel_name, options in panel_to_options.items():
628        if panel_name == OPTIONS_PANEL_TITLE:
629            # Already printed above
630            continue
631        _print_options_panel(
632            name=panel_name,
633            params=options,
634            ctx=ctx,
635            markup_mode=markup_mode,
636            console=console,
637        )
638
639    if isinstance(obj, click.Group):
640        panel_to_commands: DefaultDict[str, List[click.Command]] = defaultdict(list)
641        for command_name in obj.list_commands(ctx):
642            command = obj.get_command(ctx, command_name)
643            if command and not command.hidden:
644                panel_name = (
645                    getattr(command, _RICH_HELP_PANEL_NAME, None)
646                    or COMMANDS_PANEL_TITLE
647                )
648                panel_to_commands[panel_name].append(command)
649
650        # Identify the longest command name in all panels
651        max_cmd_len = max(
652            [
653                len(command.name or "")
654                for commands in panel_to_commands.values()
655                for command in commands
656            ],
657            default=0,
658        )
659
660        # Print each command group panel
661        default_commands = panel_to_commands.get(COMMANDS_PANEL_TITLE, [])
662        _print_commands_panel(
663            name=COMMANDS_PANEL_TITLE,
664            commands=default_commands,
665            markup_mode=markup_mode,
666            console=console,
667            cmd_len=max_cmd_len,
668        )
669        for panel_name, commands in panel_to_commands.items():
670            if panel_name == COMMANDS_PANEL_TITLE:
671                # Already printed above
672                continue
673            _print_commands_panel(
674                name=panel_name,
675                commands=commands,
676                markup_mode=markup_mode,
677                console=console,
678                cmd_len=max_cmd_len,
679            )
680
681    # Epilogue if we have it
682    if obj.epilog:
683        # Remove single linebreaks, replace double with single
684        lines = obj.epilog.split("\n\n")
685        epilogue = "\n".join([x.replace("\n", " ").strip() for x in lines])
686        epilogue_text = _make_rich_text(text=epilogue, markup_mode=markup_mode)
687        console.print(Padding(Align(epilogue_text, pad=False), 1))
688
689
690def rich_format_error(self: click.ClickException) -> None:
691    """Print richly formatted click errors.
692
693    Called by custom exception handler to print richly formatted click errors.
694    Mimics original click.ClickException.echo() function but with rich formatting.
695    """
696    console = _get_rich_console(stderr=True)
697    ctx: Union[click.Context, None] = getattr(self, "ctx", None)
698    if ctx is not None:
699        console.print(ctx.get_usage())
700
701    if ctx is not None and ctx.command.get_help_option(ctx) is not None:
702        console.print(
703            RICH_HELP.format(
704                command_path=ctx.command_path, help_option=ctx.help_option_names[0]
705            ),
706            style=STYLE_ERRORS_SUGGESTION,
707        )
708
709    console.print(
710        Panel(
711            highlighter(self.format_message()),
712            border_style=STYLE_ERRORS_PANEL_BORDER,
713            title=ERRORS_PANEL_TITLE,
714            title_align=ALIGN_ERRORS_PANEL,
715        )
716    )
717
718
719def rich_abort_error() -> None:
720    """Print richly formatted abort error."""
721    console = _get_rich_console(stderr=True)
722    console.print(ABORTED_TEXT, style=STYLE_ABORTED)
723
724
725def rich_to_html(input_text: str) -> str:
726    """Print the HTML version of a rich-formatted input string.
727
728    This function does not provide a full HTML page, but can be used to insert
729    HTML-formatted text spans into a markdown file.
730    """
731    console = Console(record=True, highlight=False, file=io.StringIO())
732
733    console.print(input_text, overflow="ignore", crop=False)
734
735    return console.export_html(inline_styles=True, code_format="{code}").strip()
736
737
738def rich_render_text(text: str) -> str:
739    """Remove rich tags and render a pure text representation"""
740    console = _get_rich_console()
741    return "".join(segment.text for segment in console.render(text)).rstrip("\n")
STYLE_OPTION = 'bold cyan'
STYLE_SWITCH = 'bold green'
STYLE_NEGATIVE_OPTION = 'bold magenta'
STYLE_NEGATIVE_SWITCH = 'bold red'
STYLE_METAVAR = 'bold yellow'
STYLE_METAVAR_SEPARATOR = 'dim'
STYLE_USAGE = 'yellow'
STYLE_USAGE_COMMAND = 'bold'
STYLE_DEPRECATED = 'red'
STYLE_DEPRECATED_COMMAND = 'dim'
STYLE_HELPTEXT_FIRST_LINE = ''
STYLE_HELPTEXT = 'dim'
STYLE_OPTION_HELP = ''
STYLE_OPTION_DEFAULT = 'dim'
STYLE_OPTION_ENVVAR = 'dim yellow'
STYLE_REQUIRED_SHORT = 'red'
STYLE_REQUIRED_LONG = 'dim red'
STYLE_OPTIONS_PANEL_BORDER = 'dim'
ALIGN_OPTIONS_PANEL: Literal['left', 'center', 'right'] = 'left'
STYLE_OPTIONS_TABLE_SHOW_LINES = False
STYLE_OPTIONS_TABLE_LEADING = 0
STYLE_OPTIONS_TABLE_PAD_EDGE = False
STYLE_OPTIONS_TABLE_PADDING = (0, 1)
STYLE_OPTIONS_TABLE_BOX = ''
STYLE_OPTIONS_TABLE_ROW_STYLES = None
STYLE_OPTIONS_TABLE_BORDER_STYLE = None
STYLE_COMMANDS_PANEL_BORDER = 'dim'
ALIGN_COMMANDS_PANEL: Literal['left', 'center', 'right'] = 'left'
STYLE_COMMANDS_TABLE_SHOW_LINES = False
STYLE_COMMANDS_TABLE_LEADING = 0
STYLE_COMMANDS_TABLE_PAD_EDGE = False
STYLE_COMMANDS_TABLE_PADDING = (0, 1)
STYLE_COMMANDS_TABLE_BOX = ''
STYLE_COMMANDS_TABLE_ROW_STYLES = None
STYLE_COMMANDS_TABLE_BORDER_STYLE = None
STYLE_COMMANDS_TABLE_FIRST_COLUMN = 'bold cyan'
STYLE_ERRORS_PANEL_BORDER = 'red'
ALIGN_ERRORS_PANEL: Literal['left', 'center', 'right'] = 'left'
STYLE_ERRORS_SUGGESTION = 'dim'
STYLE_ABORTED = 'red'
MAX_WIDTH = None
COLOR_SYSTEM: Optional[Literal['auto', 'standard', '256', 'truecolor', 'windows']] = 'auto'
FORCE_TERMINAL = True
DEPRECATED_STRING = '(deprecated) '
DEFAULT_STRING = '[default: {}]'
ENVVAR_STRING = '[env var: {}]'
REQUIRED_SHORT_STRING = '*'
REQUIRED_LONG_STRING = '[required]'
RANGE_STRING = ' [{}]'
ARGUMENTS_PANEL_TITLE = 'Arguments'
OPTIONS_PANEL_TITLE = 'Options'
COMMANDS_PANEL_TITLE = 'Commands'
ERRORS_PANEL_TITLE = 'Error'
ABORTED_TEXT = 'Aborted.'
RICH_HELP = "Try [blue]'{command_path} {help_option}'[/] for help."
MARKUP_MODE_MARKDOWN = 'markdown'
MARKUP_MODE_RICH = 'rich'
MarkupMode = typing.Literal['markdown', 'rich', None]
class OptionHighlighter(rich.highlighter.RegexHighlighter):
108class OptionHighlighter(RegexHighlighter):
109    """Highlights our special options."""
110
111    highlights = [
112        r"(^|\W)(?P<switch>\-\w+)(?![a-zA-Z0-9])",
113        r"(^|\W)(?P<option>\-\-[\w\-]+)(?![a-zA-Z0-9])",
114        r"(?P<metavar>\<[^\>]+\>)",
115        r"(?P<usage>Usage: )",
116    ]

Highlights our special options.

highlights = ['(^|\\W)(?P<switch>\\-\\w+)(?![a-zA-Z0-9])', '(^|\\W)(?P<option>\\-\\-[\\w\\-]+)(?![a-zA-Z0-9])', '(?P<metavar>\\<[^\\>]+\\>)', '(?P<usage>Usage: )']
Inherited Members
rich.highlighter.RegexHighlighter
base_style
highlight
class NegativeOptionHighlighter(rich.highlighter.RegexHighlighter):
119class NegativeOptionHighlighter(RegexHighlighter):
120    highlights = [
121        r"(^|\W)(?P<negative_switch>\-\w+)(?![a-zA-Z0-9])",
122        r"(^|\W)(?P<negative_option>\-\-[\w\-]+)(?![a-zA-Z0-9])",
123    ]

Applies highlighting from a list of regular expressions.

highlights = ['(^|\\W)(?P<negative_switch>\\-\\w+)(?![a-zA-Z0-9])', '(^|\\W)(?P<negative_option>\\-\\-[\\w\\-]+)(?![a-zA-Z0-9])']
Inherited Members
rich.highlighter.RegexHighlighter
base_style
highlight
highlighter = <OptionHighlighter object>
negative_highlighter = <NegativeOptionHighlighter object>
def rich_format_help( *, obj: Union[click.core.Command, click.core.Group], ctx: click.core.Context, markup_mode: Literal['markdown', 'rich', None]) -> None:
549def rich_format_help(
550    *,
551    obj: Union[click.Command, click.Group],
552    ctx: click.Context,
553    markup_mode: MarkupMode,
554) -> None:
555    """Print nicely formatted help text using rich.
556
557    Based on original code from rich-cli, by @willmcgugan.
558    https://github.com/Textualize/rich-cli/blob/8a2767c7a340715fc6fbf4930ace717b9b2fc5e5/src/rich_cli/__main__.py#L162-L236
559
560    Replacement for the click function format_help().
561    Takes a command or group and builds the help text output.
562    """
563    console = _get_rich_console()
564
565    # Print usage
566    console.print(
567        Padding(highlighter(obj.get_usage(ctx)), 1), style=STYLE_USAGE_COMMAND
568    )
569
570    # Print command / group help if we have some
571    if obj.help:
572        # Print with some padding
573        console.print(
574            Padding(
575                Align(
576                    _get_help_text(
577                        obj=obj,
578                        markup_mode=markup_mode,
579                    ),
580                    pad=False,
581                ),
582                (0, 1, 1, 1),
583            )
584        )
585    panel_to_arguments: DefaultDict[str, List[click.Argument]] = defaultdict(list)
586    panel_to_options: DefaultDict[str, List[click.Option]] = defaultdict(list)
587    for param in obj.get_params(ctx):
588        # Skip if option is hidden
589        if getattr(param, "hidden", False):
590            continue
591        if isinstance(param, click.Argument):
592            panel_name = (
593                getattr(param, _RICH_HELP_PANEL_NAME, None) or ARGUMENTS_PANEL_TITLE
594            )
595            panel_to_arguments[panel_name].append(param)
596        elif isinstance(param, click.Option):
597            panel_name = (
598                getattr(param, _RICH_HELP_PANEL_NAME, None) or OPTIONS_PANEL_TITLE
599            )
600            panel_to_options[panel_name].append(param)
601    default_arguments = panel_to_arguments.get(ARGUMENTS_PANEL_TITLE, [])
602    _print_options_panel(
603        name=ARGUMENTS_PANEL_TITLE,
604        params=default_arguments,
605        ctx=ctx,
606        markup_mode=markup_mode,
607        console=console,
608    )
609    for panel_name, arguments in panel_to_arguments.items():
610        if panel_name == ARGUMENTS_PANEL_TITLE:
611            # Already printed above
612            continue
613        _print_options_panel(
614            name=panel_name,
615            params=arguments,
616            ctx=ctx,
617            markup_mode=markup_mode,
618            console=console,
619        )
620    default_options = panel_to_options.get(OPTIONS_PANEL_TITLE, [])
621    _print_options_panel(
622        name=OPTIONS_PANEL_TITLE,
623        params=default_options,
624        ctx=ctx,
625        markup_mode=markup_mode,
626        console=console,
627    )
628    for panel_name, options in panel_to_options.items():
629        if panel_name == OPTIONS_PANEL_TITLE:
630            # Already printed above
631            continue
632        _print_options_panel(
633            name=panel_name,
634            params=options,
635            ctx=ctx,
636            markup_mode=markup_mode,
637            console=console,
638        )
639
640    if isinstance(obj, click.Group):
641        panel_to_commands: DefaultDict[str, List[click.Command]] = defaultdict(list)
642        for command_name in obj.list_commands(ctx):
643            command = obj.get_command(ctx, command_name)
644            if command and not command.hidden:
645                panel_name = (
646                    getattr(command, _RICH_HELP_PANEL_NAME, None)
647                    or COMMANDS_PANEL_TITLE
648                )
649                panel_to_commands[panel_name].append(command)
650
651        # Identify the longest command name in all panels
652        max_cmd_len = max(
653            [
654                len(command.name or "")
655                for commands in panel_to_commands.values()
656                for command in commands
657            ],
658            default=0,
659        )
660
661        # Print each command group panel
662        default_commands = panel_to_commands.get(COMMANDS_PANEL_TITLE, [])
663        _print_commands_panel(
664            name=COMMANDS_PANEL_TITLE,
665            commands=default_commands,
666            markup_mode=markup_mode,
667            console=console,
668            cmd_len=max_cmd_len,
669        )
670        for panel_name, commands in panel_to_commands.items():
671            if panel_name == COMMANDS_PANEL_TITLE:
672                # Already printed above
673                continue
674            _print_commands_panel(
675                name=panel_name,
676                commands=commands,
677                markup_mode=markup_mode,
678                console=console,
679                cmd_len=max_cmd_len,
680            )
681
682    # Epilogue if we have it
683    if obj.epilog:
684        # Remove single linebreaks, replace double with single
685        lines = obj.epilog.split("\n\n")
686        epilogue = "\n".join([x.replace("\n", " ").strip() for x in lines])
687        epilogue_text = _make_rich_text(text=epilogue, markup_mode=markup_mode)
688        console.print(Padding(Align(epilogue_text, pad=False), 1))

Print nicely formatted help text using rich.

Based on original code from rich-cli, by @willmcgugan. https://github.com/Textualize/rich-cli/blob/8a2767c7a340715fc6fbf4930ace717b9b2fc5e5/src/rich_cli/__main__.py#L162-L236

Replacement for the click function format_help(). Takes a command or group and builds the help text output.

def rich_format_error(self: click.exceptions.ClickException) -> None:
691def rich_format_error(self: click.ClickException) -> None:
692    """Print richly formatted click errors.
693
694    Called by custom exception handler to print richly formatted click errors.
695    Mimics original click.ClickException.echo() function but with rich formatting.
696    """
697    console = _get_rich_console(stderr=True)
698    ctx: Union[click.Context, None] = getattr(self, "ctx", None)
699    if ctx is not None:
700        console.print(ctx.get_usage())
701
702    if ctx is not None and ctx.command.get_help_option(ctx) is not None:
703        console.print(
704            RICH_HELP.format(
705                command_path=ctx.command_path, help_option=ctx.help_option_names[0]
706            ),
707            style=STYLE_ERRORS_SUGGESTION,
708        )
709
710    console.print(
711        Panel(
712            highlighter(self.format_message()),
713            border_style=STYLE_ERRORS_PANEL_BORDER,
714            title=ERRORS_PANEL_TITLE,
715            title_align=ALIGN_ERRORS_PANEL,
716        )
717    )

Print richly formatted click errors.

Called by custom exception handler to print richly formatted click errors. Mimics original click.ClickException.echo() function but with rich formatting.

def rich_abort_error() -> None:
720def rich_abort_error() -> None:
721    """Print richly formatted abort error."""
722    console = _get_rich_console(stderr=True)
723    console.print(ABORTED_TEXT, style=STYLE_ABORTED)

Print richly formatted abort error.

def rich_to_html(input_text: str) -> str:
726def rich_to_html(input_text: str) -> str:
727    """Print the HTML version of a rich-formatted input string.
728
729    This function does not provide a full HTML page, but can be used to insert
730    HTML-formatted text spans into a markdown file.
731    """
732    console = Console(record=True, highlight=False, file=io.StringIO())
733
734    console.print(input_text, overflow="ignore", crop=False)
735
736    return console.export_html(inline_styles=True, code_format="{code}").strip()

Print the HTML version of a rich-formatted input string.

This function does not provide a full HTML page, but can be used to insert HTML-formatted text spans into a markdown file.

def rich_render_text(text: str) -> str:
739def rich_render_text(text: str) -> str:
740    """Remove rich tags and render a pure text representation"""
741    console = _get_rich_console()
742    return "".join(segment.text for segment in console.render(text)).rstrip("\n")

Remove rich tags and render a pure text representation