diff --git a/tests/assets/subcommand_tree.py b/tests/assets/subcommand_tree.py new file mode 100755 index 0000000000..54c99d8fed --- /dev/null +++ b/tests/assets/subcommand_tree.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import typer + +version_app = typer.Typer() + + +@version_app.command(help="Print CLI version and exit") +def version(): + print("My CLI Version 1.0") + + +users_app = typer.Typer(no_args_is_help=True, help="Manage users") + + +@users_app.command("add", help="Really long help", short_help="Short help") +def add_func(name: str, address: str = None): + extension = "" + if address: + extension = f" at {address}" + print(f"Adding user: {name}{extension}") + + +@users_app.command("delete") +def delete_func(name: str): + print(f"Deleting user: {name}") + + +@users_app.command("annoy", hidden=True, help="Ill advised annoying someone") +def annoy_user(name: str): + print(f"Annoying {name}") + + +user_update_app = typer.Typer(help="Update user info") + + +@user_update_app.command("name", short_help="change name") +def update_user_name(old: str, new: str): + print(f"Updating user: {old} => {new}") + + +@user_update_app.command("address", short_help="change address") +def update_user_addr(name: str, address: str): + print(f"Updating user {name} address: {address}") + + +users_app.add_typer(user_update_app, name="update") + +pets_app = typer.Typer(no_args_is_help=True) + + +@pets_app.command("add", short_help="add pet") +def add_pet(name: str): + print(f"Adding pet named {name}") + + +@pets_app.command("list") +def list_pets(): + print("Need to compile list of pets") + + +app = typer.Typer(no_args_is_help=True, command_tree=True, help="Random help") +app.add_typer(version_app) +app.add_typer(users_app, name="users") +app.add_typer(pets_app, name="pets") + + +if __name__ == "__main__": + app() diff --git a/tests/test_command_tree.py b/tests/test_command_tree.py new file mode 100644 index 0000000000..387a20cc39 --- /dev/null +++ b/tests/test_command_tree.py @@ -0,0 +1,199 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +SUBCOMMANDS = Path(__file__).parent / "assets/subcommand_tree.py" +SUBCMD_FLAG = "--show-sub-commands" +SUBCMD_HELP = "Show sub-command tree" +SUBCMD_TITLE = "Sub-Commands" +SUBCMD_FOOTNOTE = "* denotes " +OVERHEAD_LINES = 3 # footnote plus top/bottom of panel + + +def prepare_lines(s: str) -> list[str]: + """ + Takes a string and massages it to a list of modified lines. + + Changes all non-ascii characters to '.', and removes trailing '.' and spaces. + """ + unified = "".join( + char if 31 < ord(char) < 127 or char == "\n" else "." for char in s + ).rstrip() + + # ignore the first 2 characters, and remove + return [line[2:].rstrip(". ") for line in unified.split("\n")] + + +def find_in_lines(lines: list[str], cmd: str, help: str) -> bool: + """ + Looks for a line that starts with 'cmd', and also contains the 'help'. + """ + for line in lines: + if line.startswith(cmd) and help in line: + return True + + return False + + +@pytest.mark.parametrize( + ["args", "expected"], + [ + pytest.param([], True, id="top"), + pytest.param(["version"], False, id="version"), + pytest.param(["users"], True, id="users"), + pytest.param(["users", "add"], False, id="users-add"), + pytest.param(["users", "delete"], False, id="users-delete"), + pytest.param(["users", "update"], True, id="users-update"), + pytest.param(["users", "update", "name"], False, id="users-update-name"), + pytest.param(["users", "update", "address"], False, id="users-update-address"), + pytest.param(["pets"], True, id="pets"), + pytest.param(["pets", "add"], False, id="pets-add"), + pytest.param(["pets", "list"], False, id="pets-list"), + ], +) +def test_subcommands_help(args: list[str], expected: bool): + full_args = ( + [sys.executable, "-m", "coverage", "run", str(SUBCOMMANDS)] + args + ["--help"] + ) + result = subprocess.run( + full_args, + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert bool(SUBCMD_FLAG in result.stdout) == expected + assert bool(SUBCMD_HELP in result.stdout) == expected + + +def test_subcommands_top_tree(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", str(SUBCOMMANDS), SUBCMD_FLAG], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + lines = prepare_lines(result.stdout) + expected = [ + ("version*", "Print CLI version and exit"), + ("users", "Manage users"), + (" add*", "Short help"), + (" delete*", ""), + (" update", "Update user info"), + (" name*", "change name"), + (" address*", "change address"), + ("pets", ""), + (" add*", "add pet"), + (" list*", ""), + ] + for command, help in expected: + assert find_in_lines(lines, command, help), f"Did not find {command} => {help}" + assert SUBCMD_FOOTNOTE in result.stdout + + assert len(lines) == len(expected) + OVERHEAD_LINES + + +def test_subcommands_users_tree(): + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + str(SUBCOMMANDS), + "users", + SUBCMD_FLAG, + ], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + lines = prepare_lines(result.stdout) + expected = [ + ("add*", "Short help"), + ("delete*", ""), + ("update", "Update user info"), + (" name*", "change name"), + (" address*", "change address"), + ] + for command, help in expected: + assert find_in_lines(lines, command, help), f"Did not find {command} => {help}" + assert not find_in_lines(lines, "annoy", "Ill advised annoying someone") + assert SUBCMD_FOOTNOTE in result.stdout + + assert len(lines) == len(expected) + OVERHEAD_LINES + + +def test_subcommands_users_update_tree(): + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + str(SUBCOMMANDS), + "users", + "update", + SUBCMD_FLAG, + ], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + lines = prepare_lines(result.stdout) + expected = [ + ("name*", "change name"), + ("address*", "change address"), + ] + for command, help in expected: + assert find_in_lines(lines, command, help), f"Did not find {command} => {help}" + assert SUBCMD_FOOTNOTE in result.stdout + + assert len(lines) == len(expected) + OVERHEAD_LINES + + +@pytest.mark.parametrize( + ["args", "message"], + [ + pytest.param(["version"], "My CLI Version 1.0", id="version"), + pytest.param( + ["users", "add", "John Doe", "--address", "55 Main St"], + "Adding user: John Doe at 55 Main St", + id="users-add", + ), + pytest.param( + ["users", "delete", "Bob Smith"], + "Deleting user: Bob Smith", + id="users-delete", + ), + pytest.param( + ["users", "annoy", "Bill"], + "Annoying Bill", + id="users-annoy", + ), + pytest.param( + ["users", "update", "name", "Jane Smith", "Bob Doe"], + "Updating user: Jane Smith => Bob Doe", + id="users-update-name", + ), + pytest.param( + ["users", "update", "address", "Bob Doe", "Drury Lane"], + "Updating user Bob Doe address: Drury Lane", + id="users-update-address", + ), + pytest.param( + ["pets", "add", "Fluffy"], "Adding pet named Fluffy", id="pets-add" + ), + pytest.param(["pets", "list"], "Need to compile list of pets", id="pets-list"), + ], +) +def test_subcommands_execute(args: list[str], message: str): + full_args = [sys.executable, "-m", "coverage", "run", str(SUBCOMMANDS)] + args + result = subprocess.run( + full_args, + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert message in result.stdout diff --git a/typer/command_tree.py b/typer/command_tree.py new file mode 100644 index 0000000000..595063cb47 --- /dev/null +++ b/typer/command_tree.py @@ -0,0 +1,89 @@ +import sys +from gettext import gettext as _ +from typing import Any + +from . import _click +from .core import DEFAULT_MARKUP_MODE, HAS_RICH, TyperCommand, TyperGroup +from .models import ParamMeta +from .params import Option +from .utils import get_params_from_function + +SUBCMD_INDENT = " " +SUBCOMMAND_TITLE = _("Sub-Commands") + + +def _subcommand_items( + command: TyperCommand | TyperGroup | Any, indent_level: int +) -> list[tuple[str, str]]: + if not isinstance(command, (TyperGroup, TyperCommand)): + return [] # pragma: no cover + + items = [] + subcommands = command.commands if isinstance(command, TyperGroup) else {} + + # get info for this command + indent = SUBCMD_INDENT * indent_level + note = "*" if not subcommands else "" + name = indent + (command.name or "unknown") + note + help = command.short_help or command.help or "" + items.append((name, help)) + + # recursively call for sub-commands with larger indent + for subcommand in subcommands.values(): + if subcommand.hidden: + continue + items.extend(_subcommand_items(subcommand, indent_level + 1)) + + return items + + +def show_command_tree( + ctx: _click.Context, + param: _click.Parameter, + value: Any, +) -> Any: + if not value or ctx.resilient_parsing: + return value # pragma: no cover + + command = ctx.command + if not isinstance(command, TyperGroup): + return value # pragma: no cover + items = [] + for subcommand in command.commands.values(): + if subcommand.hidden: + continue + items.extend(_subcommand_items(subcommand, 0)) + + if items: + markup_mode = DEFAULT_MARKUP_MODE + if not HAS_RICH or markup_mode is None: # pragma: no cover + formatter = ctx.make_formatter() + formatter.section(SUBCOMMAND_TITLE) + formatter.write_dl(items) + content = formatter.getvalue().rstrip("\n") + _click.echo(content) + else: + from . import rich_utils + + rich_utils.rich_format_subcommands(ctx, items) + + sys.exit(0) + + +# Create a fake command function to extract parameters +def _show_command_tree_placeholder_function( + show_command_tree: bool = Option( + None, + "--show-sub-commands", + callback=show_command_tree, + expose_value=False, + help="Show sub-command tree", + ), +) -> Any: + pass # pragma: no cover + + +def get_command_tree_param_meta() -> ParamMeta: + parameters = get_params_from_function(_show_command_tree_placeholder_function) + meta_values = list(parameters.values()) # currently only one value + return meta_values[0] diff --git a/typer/main.py b/typer/main.py index 700648da66..61a3664804 100644 --- a/typer/main.py +++ b/typer/main.py @@ -22,6 +22,7 @@ from ._click import types from ._click.globals import get_current_context from ._typing import get_args, get_origin, is_literal_type, is_union, literal_values +from .command_tree import get_command_tree_param_meta from .completion import get_completion_inspect_parameters from .core import ( DEFAULT_MARKUP_MODE, @@ -116,6 +117,12 @@ def get_install_completion_arguments() -> tuple[_click.Parameter, _click.Paramet return click_install_param, click_show_param +def get_command_tree_parameter() -> _click.Parameter: + meta = get_command_tree_param_meta() + param, _ = get_click_param(meta) + return param + + class Typer: """ `Typer` main class, the main entrypoint to use Typer. @@ -408,6 +415,23 @@ def callback(): """ ), ] = True, + command_tree: Annotated[ + bool, + Doc( + """ + Toggle whether or not to add the `--show-sub-commands` option to the app. + Set to `False` by default. + + ***Example*** + + ```python + import typer + + app = typer.Typer(command_tree=True) + ``` + """ + ), + ] = Default(False), # Rich settings rich_markup_mode: Annotated[ MarkupMode, @@ -518,6 +542,7 @@ def callback(): ] = True, ): self._add_completion = add_completion + self._command_tree = command_tree self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel self.suggest_commands = suggest_commands @@ -1165,6 +1190,7 @@ def get_group(typer_instance: Typer) -> TyperGroup: TyperInfo(typer_instance), pretty_exceptions_short=typer_instance.pretty_exceptions_short, rich_markup_mode=typer_instance.rich_markup_mode, + command_tree=typer_instance._command_tree, suggest_commands=typer_instance.suggest_commands, ) return group @@ -1286,6 +1312,7 @@ def get_group_from_info( pretty_exceptions_short: bool, suggest_commands: bool, rich_markup_mode: MarkupMode, + command_tree: bool, ) -> TyperGroup: assert group_info.typer_instance, ( "A Typer instance is needed to generate a Click Group" @@ -1304,6 +1331,7 @@ def get_group_from_info( sub_group_info, pretty_exceptions_short=pretty_exceptions_short, rich_markup_mode=rich_markup_mode, + command_tree=command_tree, suggest_commands=suggest_commands, ) if sub_group.name: @@ -1324,6 +1352,8 @@ def get_group_from_info( convertors, context_param_name, ) = get_params_convertors_ctx_param_name_from_function(solved_info.callback) + if command_tree: + params.append(get_command_tree_parameter()) cls = solved_info.cls or TyperGroup assert issubclass(cls, TyperGroup), f"{cls} should be a subclass of {TyperGroup}" group = cls( @@ -1372,8 +1402,11 @@ def get_default_option_flag_name(name: str, metavar: str | None) -> str: def get_params_convertors_ctx_param_name_from_function( callback: Callable[..., Any] | None, -) -> tuple[list[TyperArgument | TyperOption], dict[str, Any], str | None]: - params = [] +) -> tuple[ + list[TyperArgument | TyperOption | _click.Parameter], dict[str, Any], str | None +]: + # NOTE: this function does NOT generate _click.Parameters, but needs to allow them to be added to the list later + params: list[TyperArgument | TyperOption | _click.Parameter] = [] convertors = {} context_param_name = None if callback: @@ -1418,7 +1451,7 @@ def get_command_from_info( context_param_name=context_param_name, pretty_exceptions_short=pretty_exceptions_short, ), - params=params, # type: ignore + params=params, help=use_help, epilog=command_info.epilog, short_help=command_info.short_help, diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 5c6e510db5..60f521da32 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -93,9 +93,11 @@ ARGUMENTS_PANEL_TITLE = _("Arguments") OPTIONS_PANEL_TITLE = _("Options") COMMANDS_PANEL_TITLE = _("Commands") +SUBCOMMANDS_PANEL_TITLE = _("Sub-Commands") ERRORS_PANEL_TITLE = _("Error") ABORTED_TEXT = _("Aborted.") RICH_HELP = _("Try [blue]'{command_path} {help_option}'[/] for help.") +SUBCOMMAND_NOTE = _("[{style}]*[/] denotes commands without subcommands") MARKUP_MODE_MARKDOWN = "markdown" MARKUP_MODE_RICH = "rich" @@ -690,6 +692,43 @@ def rich_format_help( console.print(Padding(Align(epilogue_text, pad=False), 1)) +def rich_format_subcommands( + ctx: _click.Context, + subcommands: list[tuple[str, str]], +) -> None: + console = _get_rich_console() + t_styles: dict[str, Any] = { + "show_lines": STYLE_OPTIONS_TABLE_SHOW_LINES, + "leading": STYLE_OPTIONS_TABLE_LEADING, + "box": STYLE_OPTIONS_TABLE_BOX, + "border_style": STYLE_OPTIONS_TABLE_BORDER_STYLE, + "row_styles": STYLE_OPTIONS_TABLE_ROW_STYLES, + "pad_edge": STYLE_OPTIONS_TABLE_PAD_EDGE, + "padding": STYLE_OPTIONS_TABLE_PADDING, + } + box_style = getattr(box, t_styles.pop("box"), None) + + subcmd_table = Table( + highlight=True, + show_header=False, + expand=True, + box=box_style, + **t_styles, + ) + subcmd_table.add_column(style=STYLE_COMMANDS_TABLE_FIRST_COLUMN) + for row in subcommands: + subcmd_table.add_row(*row) + console.print( + Panel( + subcmd_table, + border_style=STYLE_OPTIONS_PANEL_BORDER, + title=SUBCOMMANDS_PANEL_TITLE, + title_align=ALIGN_OPTIONS_PANEL, + ) + ) + console.print(SUBCOMMAND_NOTE.format(style=STYLE_COMMANDS_TABLE_FIRST_COLUMN)) + + def rich_format_error(self: _click.ClickException) -> None: """Print richly formatted click errors.