diff --git a/tests/assets/cli/multi_app.py b/tests/assets/cli/multi_app.py index 1442aa2442..2c3e33ae26 100644 --- a/tests/assets/cli/multi_app.py +++ b/tests/assets/cli/multi_app.py @@ -13,14 +13,14 @@ def hello(name: str = "World", age: int = typer.Option(0, help="The age of the u typer.echo(f"Hello {name}") -@sub_app.command() +@sub_app.command(rich_help_panel="Greet") def hi(user: str = typer.Argument("World", help="The name of the user to greet")): """ Say Hi """ -@sub_app.command() +@sub_app.command(rich_help_panel="Farewell") def bye(): """ Say bye @@ -32,7 +32,7 @@ def bye(): app.add_typer(sub_app, name="sub") -@app.command() +@app.command(rich_help_panel="") def top(): """ Top command diff --git a/tests/assets/cli/multiapp-docs-norich.md b/tests/assets/cli/multiapp-docs-norich.md new file mode 100644 index 0000000000..67d02568db --- /dev/null +++ b/tests/assets/cli/multiapp-docs-norich.md @@ -0,0 +1,102 @@ +# `multiapp` + +Demo App + +**Usage**: + +```console +$ multiapp [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--install-completion`: Install completion for the current shell. +* `--show-completion`: Show completion for the current shell, to copy it or customize the installation. +* `--help`: Show this message and exit. + +The end + +**Commands**: + +* `top`: Top command +* `sub` + +## `multiapp top` + +Top command + +**Usage**: + +```console +$ multiapp top [OPTIONS] +``` + +**Options**: + +* `--help`: Show this message and exit. + +## `multiapp sub` + +**Usage**: + +```console +$ multiapp sub [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `hello`: Say Hello +* `hi`: Say Hi +* `bye`: Say bye + +### `multiapp sub hello` + +Say Hello + +**Usage**: + +```console +$ multiapp sub hello [OPTIONS] +``` + +**Options**: + +* `--name TEXT`: [default: World] +* `--age INTEGER`: The age of the user [default: 0] +* `--help`: Show this message and exit. + +### `multiapp sub hi` + +Say Hi + +**Usage**: + +```console +$ multiapp sub hi [OPTIONS] [USER] +``` + +**Arguments**: + +* `[USER]`: The name of the user to greet [default: World] + +**Options**: + +* `--help`: Show this message and exit. + +### `multiapp sub bye` + +Say bye + +**Usage**: + +```console +$ multiapp sub bye [OPTIONS] +``` + +**Options**: + +* `--help`: Show this message and exit. diff --git a/tests/assets/cli/multiapp-docs-title.md b/tests/assets/cli/multiapp-docs-title.md index ffde843736..8c2f941c79 100644 --- a/tests/assets/cli/multiapp-docs-title.md +++ b/tests/assets/cli/multiapp-docs-title.md @@ -50,7 +50,13 @@ $ multiapp sub [OPTIONS] COMMAND [ARGS]... **Commands**: * `hello`: Say Hello + +**Greet**: + * `hi`: Say Hi + +**Farewell**: + * `bye`: Say bye ### `multiapp sub hello` diff --git a/tests/assets/cli/multiapp-docs.md b/tests/assets/cli/multiapp-docs.md index 67d02568db..d02c4d5187 100644 --- a/tests/assets/cli/multiapp-docs.md +++ b/tests/assets/cli/multiapp-docs.md @@ -50,7 +50,13 @@ $ multiapp sub [OPTIONS] COMMAND [ARGS]... **Commands**: * `hello`: Say Hello + +**Greet**: + * `hi`: Say Hi + +**Farewell**: + * `bye`: Say bye ### `multiapp sub hello` diff --git a/tests/test_cli/test_doc.py b/tests/test_cli/test_doc.py index 1da8bb73ef..439685225b 100644 --- a/tests/test_cli/test_doc.py +++ b/tests/test_cli/test_doc.py @@ -104,7 +104,9 @@ def test_doc_no_rich(): capture_output=True, encoding="utf-8", ) - docs_path: Path = Path(__file__).parent.parent / "assets/cli/multiapp-docs.md" + docs_path: Path = ( + Path(__file__).parent.parent / "assets/cli/multiapp-docs-norich.md" + ) docs = docs_path.read_text() assert docs in result.stdout assert "**Arguments**" in result.stdout @@ -138,7 +140,7 @@ def test_doc_no_typer(): "run", "-m", "typer", - "tests/assets/cli/empty_script.py", + "tests.assets.cli.empty_script", "utils", "docs", ], diff --git a/typer/cli.py b/typer/cli.py index 4b4356f8bd..6be6bed338 100644 --- a/typer/cli.py +++ b/typer/cli.py @@ -1,6 +1,8 @@ import importlib.util import re import sys +from collections import defaultdict +from itertools import chain from pathlib import Path from typing import Any @@ -244,26 +246,50 @@ def get_docs_for_click( if isinstance(obj, Group): group = obj commands = group.list_commands(ctx) + default_panel_name = "Commands" + if HAS_RICH: + from . import rich_utils + + default_panel_name = rich_utils.COMMANDS_PANEL_TITLE if commands: - docs += "**Commands**:\n\n" + panel_to_commands: dict[str, list[click.Command]] = defaultdict(list) for command in commands: command_obj = group.get_command(ctx, command) assert command_obj - docs += f"* `{command_obj.name}`" - command_help = command_obj.get_short_help_str() - if command_help: - docs += f": {_parse_html(to_parse, command_help)}" + panel_name = default_panel_name + if HAS_RICH: + from . import rich_utils + + panel_name = rich_utils.get_panel_name( + command_obj, default_panel_name + ) + panel_to_commands[panel_name].append(command_obj) + if HAS_RICH: + # Ensure that the ungrouped commands show up first + default_command_objs = panel_to_commands.pop(default_panel_name, []) + if len(default_command_objs) > 0: + panel_to_commands = { + default_panel_name: default_command_objs, + **panel_to_commands, + } + for panel_name, command_objs in panel_to_commands.items(): + docs += f"**{panel_name}**:\n\n" + for command_obj in command_objs: + docs += f"* `{command_obj.name}`" + command_help = command_obj.get_short_help_str() + if command_help: + docs += f": {_parse_html(to_parse, command_help)}" + docs += "\n" docs += "\n" - docs += "\n" - for command in commands: - command_obj = group.get_command(ctx, command) - assert command_obj - use_prefix = "" - if command_name: - use_prefix += f"{command_name}" - docs += get_docs_for_click( - obj=command_obj, ctx=ctx, indent=indent + 1, call_prefix=use_prefix - ) + for command_obj in chain.from_iterable( + command_objs for command_objs in panel_to_commands.values() + ): + use_prefix = "" + if command_name: + use_prefix += f"{command_name}" + docs += get_docs_for_click( + obj=command_obj, ctx=ctx, indent=indent + 1, call_prefix=use_prefix + ) return docs diff --git a/typer/rich_utils.py b/typer/rich_utils.py index d85043238c..c1519684e4 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -575,14 +575,10 @@ def rich_format_help( if getattr(param, "hidden", False): continue if isinstance(param, click.Argument): - panel_name = ( - getattr(param, _RICH_HELP_PANEL_NAME, None) or ARGUMENTS_PANEL_TITLE - ) + panel_name = get_panel_name(param, ARGUMENTS_PANEL_TITLE) panel_to_arguments[panel_name].append(param) elif isinstance(param, click.Option): - panel_name = ( - getattr(param, _RICH_HELP_PANEL_NAME, None) or OPTIONS_PANEL_TITLE - ) + panel_name = get_panel_name(param, OPTIONS_PANEL_TITLE) panel_to_options[panel_name].append(param) default_arguments = panel_to_arguments.get(ARGUMENTS_PANEL_TITLE, []) _print_options_panel( @@ -628,10 +624,7 @@ def rich_format_help( for command_name in obj.list_commands(ctx): command = obj.get_command(ctx, command_name) if command and not command.hidden: - panel_name = ( - getattr(command, _RICH_HELP_PANEL_NAME, None) - or COMMANDS_PANEL_TITLE - ) + panel_name = get_panel_name(command, COMMANDS_PANEL_TITLE) panel_to_commands[panel_name].append(command) # Identify the longest command name in all panels @@ -751,3 +744,9 @@ def get_traceback( width=MAX_WIDTH, ) return rich_tb + + +def get_panel_name( + obj: click.Command | click.Argument | click.Option, default_name: str +) -> str: + return getattr(obj, _RICH_HELP_PANEL_NAME, None) or default_name