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..2ac6e8cc41 --- /dev/null +++ b/tests/test_command_tree.py @@ -0,0 +1,204 @@ +import subprocess +import sys +from pathlib import Path +from typing import List + +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 + if expected: + assert SUBCMD_FLAG in result.stdout + assert SUBCMD_HELP in result.stdout + else: + assert SUBCMD_FLAG not in result.stdout + assert SUBCMD_HELP not in result.stdout + + +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..fd834e9256 --- /dev/null +++ b/typer/command_tree.py @@ -0,0 +1,103 @@ +import sys +from gettext import gettext as _ +from typing import Any, Dict, List, Tuple + +import click + +from .models import ParamMeta +from .params import Option +from .utils import get_params_from_function + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +MarkupMode = Literal["markdown", "rich", None] + +try: + import rich + + from . import rich_utils + + DEFAULT_MARKUP_MODE: MarkupMode = "rich" + +except ImportError: # pragma: no cover + rich = None # type: ignore + DEFAULT_MARKUP_MODE = None + + +SUBCMD_INDENT = " " +SUBCOMMAND_TITLE = _("Sub-Commands") + + +def _commands_from_info( + info: Dict[str, Any], indent_level: int +) -> List[Tuple[str, str]]: + items = [] + subcommands = info.get("commands", {}) + + # get info for this command + indent = SUBCMD_INDENT * indent_level + note = "*" if not subcommands else "" + name = indent + info.get("name", "unknown") + note + help = info.get("short_help") or info.get("help") or "" + items.append((name, help)) + + # recursively call for sub-commands with larger indent + for subcommand in subcommands.values(): + if subcommand.get("hidden", False): + continue + items.extend(_commands_from_info(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 + + info = ctx.to_info_dict() + subcommands = info.get("command", {}).get("commands", {}) # skips top-level + + items = [] + for subcommand in subcommands.values(): + if subcommand.get("hidden", False): + continue + items.extend(_commands_from_info(subcommand, 0)) + + if items: + markup_mode = DEFAULT_MARKUP_MODE + if not 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: + 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 36737e49ef..32a78b0c34 100644 --- a/typer/main.py +++ b/typer/main.py @@ -18,6 +18,7 @@ from typing_extensions import get_args, get_origin from ._typing import is_union +from .command_tree import get_command_tree_param_meta from .completion import get_completion_inspect_parameters from .core import ( DEFAULT_MARKUP_MODE, @@ -125,6 +126,12 @@ def get_install_completion_arguments() -> Tuple[click.Parameter, click.Parameter 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: def __init__( self, @@ -147,6 +154,7 @@ def __init__( hidden: bool = Default(False), deprecated: bool = Default(False), add_completion: bool = True, + command_tree: bool = Default(False), # Rich settings rich_markup_mode: MarkupMode = Default(DEFAULT_MARKUP_MODE), rich_help_panel: Union[str, None] = Default(None), @@ -155,6 +163,7 @@ def __init__( pretty_exceptions_short: bool = 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.pretty_exceptions_enable = pretty_exceptions_enable @@ -345,6 +354,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, ) return group @@ -472,6 +482,7 @@ def get_group_from_info( *, pretty_exceptions_short: bool, rich_markup_mode: MarkupMode, + command_tree: bool, ) -> TyperGroup: assert ( group_info.typer_instance @@ -490,6 +501,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, ) if sub_group.name: commands[sub_group.name] = sub_group @@ -509,6 +521,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()) # type: ignore[arg-type] cls = solved_info.cls or TyperGroup assert issubclass(cls, TyperGroup), f"{cls} should be a subclass of {TyperGroup}" group = cls( diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 7d603da2d7..3dde96348d 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -6,7 +6,7 @@ from collections import defaultdict from gettext import gettext as _ from os import getenv -from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Union +from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Tuple, Union import click from rich import box @@ -63,6 +63,7 @@ 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" @@ -91,9 +92,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" @@ -677,6 +680,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.