From 7f23704e86dd521703a0152bac1cafee90fffb11 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Tue, 7 Jan 2025 20:40:08 -0500 Subject: [PATCH 01/16] Add ability to show sub-commands --- tests/assets/subcommand_tree.py | 69 +++++++++++ tests/test_command_tree.py | 204 ++++++++++++++++++++++++++++++++ typer/command_tree.py | 98 +++++++++++++++ typer/main.py | 14 +++ typer/rich_utils.py | 42 ++++++- 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100755 tests/assets/subcommand_tree.py create mode 100644 tests/test_command_tree.py create mode 100644 typer/command_tree.py 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..1ecf5bb329 --- /dev/null +++ b/typer/command_tree.py @@ -0,0 +1,98 @@ +import sys +from gettext import gettext as _ +from typing import Any, Dict, List, Literal, Tuple + +import click + +from .models import ParamMeta +from .params import Option +from .utils import get_params_from_function + +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. From 50fd08866f30581056664a65226d32b3a302e46e Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Thu, 9 Jan 2025 18:59:38 -0500 Subject: [PATCH 02/16] Fix Literal typing issue for Python 3.7 --- typer/command_tree.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/typer/command_tree.py b/typer/command_tree.py index 1ecf5bb329..fd834e9256 100644 --- a/typer/command_tree.py +++ b/typer/command_tree.py @@ -1,6 +1,6 @@ import sys from gettext import gettext as _ -from typing import Any, Dict, List, Literal, Tuple +from typing import Any, Dict, List, Tuple import click @@ -8,6 +8,11 @@ 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: From 89595098021a6231ce4a09b609ad30eca2e4ddc8 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Mon, 22 Sep 2025 16:48:52 -0400 Subject: [PATCH 03/16] Fix some import issues with rich --- typer/command_tree.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/typer/command_tree.py b/typer/command_tree.py index fd834e9256..1e091e6291 100644 --- a/typer/command_tree.py +++ b/typer/command_tree.py @@ -4,26 +4,19 @@ import click +from ._typing import Literal 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 @@ -72,7 +65,7 @@ def show_command_tree( if items: markup_mode = DEFAULT_MARKUP_MODE - if not rich or markup_mode is None: # pragma: no cover + if markup_mode is None: # pragma: no cover formatter = ctx.make_formatter() formatter.section(SUBCOMMAND_TITLE) formatter.write_dl(items) From 5c8c4ace82f0abad4bff377c79dd000ccc696d53 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Mon, 22 Sep 2025 19:22:29 -0400 Subject: [PATCH 04/16] Use lazy loading of rich_utils, and rich variables from core --- typer/command_tree.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/typer/command_tree.py b/typer/command_tree.py index 1e091e6291..ee96e59453 100644 --- a/typer/command_tree.py +++ b/typer/command_tree.py @@ -4,22 +4,11 @@ import click -from ._typing import Literal +from .core import DEFAULT_MARKUP_MODE, HAS_RICH from .models import ParamMeta from .params import Option from .utils import get_params_from_function -MarkupMode = Literal["markdown", "rich", None] - -try: - from . import rich_utils - - DEFAULT_MARKUP_MODE: MarkupMode = "rich" - -except ImportError: # pragma: no cover - DEFAULT_MARKUP_MODE = None - - SUBCMD_INDENT = " " SUBCOMMAND_TITLE = _("Sub-Commands") @@ -65,13 +54,15 @@ def show_command_tree( if items: markup_mode = DEFAULT_MARKUP_MODE - if markup_mode is None: # pragma: no cover + 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) From 593611193e6076809a449ad79a3708f8ff8c3577 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 02:57:53 +0000 Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/rich_utils.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/typer/rich_utils.py b/typer/rich_utils.py index a5e9e18781..cb649d7fb4 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -5,7 +5,17 @@ from collections import defaultdict from gettext import gettext as _ from os import getenv -from typing import Any, DefaultDict, Dict, Iterable, List, Literal, Optional, Tuple, Union +from typing import ( + Any, + DefaultDict, + Dict, + Iterable, + List, + Literal, + Optional, + Tuple, + Union, +) import click from rich import box From 6a04f599b2c200e832daf9d8864dac79a7f9c596 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:29:26 +0000 Subject: [PATCH 06/16] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/rich_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 5d072f1bec..afefae7270 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from gettext import gettext as _ from os import getenv -from typing import Any, Literal, Optional, Thple, Union +from typing import Any, Literal, Optional, Union import click from rich import box From 3d28d55ae6ebd94d8ccf45a6854170f3f544ef4e Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Fri, 26 Dec 2025 07:44:58 -0500 Subject: [PATCH 07/16] Use latest builtins --- tests/test_command_tree.py | 9 ++++----- typer/command_tree.py | 6 +++--- typer/rich_utils.py | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/test_command_tree.py b/tests/test_command_tree.py index 2ac6e8cc41..a312d9e273 100644 --- a/tests/test_command_tree.py +++ b/tests/test_command_tree.py @@ -1,7 +1,6 @@ import subprocess import sys from pathlib import Path -from typing import List import pytest @@ -13,7 +12,7 @@ OVERHEAD_LINES = 3 # footnote plus top/bottom of panel -def prepare_lines(s: str) -> List[str]: +def prepare_lines(s: str) -> list[str]: """ Takes a string and massages it to a list of modified lines. @@ -27,7 +26,7 @@ def prepare_lines(s: str) -> List[str]: return [line[2:].rstrip(". ") for line in unified.split("\n")] -def find_in_lines(lines: List[str], cmd: str, help: str) -> bool: +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'. """ @@ -54,7 +53,7 @@ def find_in_lines(lines: List[str], cmd: str, help: str) -> bool: pytest.param(["pets", "list"], False, id="pets-list"), ], ) -def test_subcommands_help(args: List[str], expected: bool): +def test_subcommands_help(args: list[str], expected: bool): full_args = ( [sys.executable, "-m", "coverage", "run", str(SUBCOMMANDS)] + args + ["--help"] ) @@ -193,7 +192,7 @@ def test_subcommands_users_update_tree(): pytest.param(["pets", "list"], "Need to compile list of pets", id="pets-list"), ], ) -def test_subcommands_execute(args: List[str], message: str): +def test_subcommands_execute(args: list[str], message: str): full_args = [sys.executable, "-m", "coverage", "run", str(SUBCOMMANDS)] + args result = subprocess.run( full_args, diff --git a/typer/command_tree.py b/typer/command_tree.py index ee96e59453..c5afccffc3 100644 --- a/typer/command_tree.py +++ b/typer/command_tree.py @@ -1,6 +1,6 @@ import sys from gettext import gettext as _ -from typing import Any, Dict, List, Tuple +from typing import Any import click @@ -14,8 +14,8 @@ def _commands_from_info( - info: Dict[str, Any], indent_level: int -) -> List[Tuple[str, str]]: + info: dict[str, Any], indent_level: int +) -> list[tuple[str, str]]: items = [] subcommands = info.get("commands", {}) diff --git a/typer/rich_utils.py b/typer/rich_utils.py index afefae7270..7b13803dc0 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -677,10 +677,10 @@ def rich_format_help( def rich_format_subcommands( ctx: click.Context, - subcommands: List[Tuple[str, str]], + subcommands: list[tuple[str, str]], ) -> None: console = _get_rich_console() - t_styles: Dict[str, Any] = { + t_styles: dict[str, Any] = { "show_lines": STYLE_OPTIONS_TABLE_SHOW_LINES, "leading": STYLE_OPTIONS_TABLE_LEADING, "box": STYLE_OPTIONS_TABLE_BOX, From ef8929ab2a3327d35f74207f06a7e5560f9a5022 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:33:02 +0000 Subject: [PATCH 08/16] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/typer/main.py b/typer/main.py index c05f63c1f4..c5a3fc2edf 100644 --- a/typer/main.py +++ b/typer/main.py @@ -414,21 +414,21 @@ def callback(): ), ] = True, command_tree: Annotated[ - bool, - Doc( - """ + 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[ From 4840b4b0bf9e214a7cf438caf2acd2193783f0c7 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Sat, 2 May 2026 08:48:12 -0400 Subject: [PATCH 09/16] Fix indentation --- typer/main.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/typer/main.py b/typer/main.py index 6423b3c5d8..c03febbba1 100644 --- a/typer/main.py +++ b/typer/main.py @@ -417,17 +417,17 @@ def callback(): bool, Doc( """ - Toggle whether or not to add the `--show-sub-commands` option to the app. - Set to `False` by default. + Toggle whether or not to add the `--show-sub-commands` option to the app. + Set to `False` by default. - ***Example*** + ***Example*** - ```python - import typer + ```python + import typer - app = typer.Typer(command_tree=True) - ``` - """ + app = typer.Typer(command_tree=True) + ``` + """ ), ] = Default(False), # Rich settings From 4ac48154482d9996fdd32fe77f256ff9e3db40c4 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Sun, 3 May 2026 07:14:37 -0400 Subject: [PATCH 10/16] Ignore issue with overly strict type checking --- typer/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index c03febbba1..238a5d62fc 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1351,7 +1351,7 @@ def get_group_from_info( 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] + params.append(get_command_tree_parameter()) # ty: ignore cls = solved_info.cls or TyperGroup assert issubclass(cls, TyperGroup), f"{cls} should be a subclass of {TyperGroup}" group = cls( From 4908daf10b93657fcaf0297c45e5d7317d5fd5b4 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Sun, 3 May 2026 10:28:03 -0400 Subject: [PATCH 11/16] Allow get_params_convertors_ctx_param_name_from_function() to return click.Parameter to avoid type issues --- typer/main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/typer/main.py b/typer/main.py index 238a5d62fc..d0da002b46 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1351,7 +1351,7 @@ def get_group_from_info( context_param_name, ) = get_params_convertors_ctx_param_name_from_function(solved_info.callback) if command_tree: - params.append(get_command_tree_parameter()) # ty: ignore + 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( @@ -1392,8 +1392,9 @@ def get_command_name(name: str) -> str: def get_params_convertors_ctx_param_name_from_function( callback: Callable[..., Any] | None, -) -> tuple[list[click.Argument | click.Option], dict[str, Any], str | None]: - params = [] +) -> tuple[list[click.Argument | click.Option | 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[click.Argument | click.Option | click.Parameter] = [] convertors = {} context_param_name = None if callback: @@ -1438,7 +1439,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, From a7be308cd6d5ea3c9248fc8cee2dec4319fa7b36 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 14:28:45 +0000 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index d0da002b46..0c5253d84f 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1392,7 +1392,9 @@ def get_command_name(name: str) -> str: def get_params_convertors_ctx_param_name_from_function( callback: Callable[..., Any] | None, -) -> tuple[list[click.Argument | click.Option | click.Parameter], dict[str, Any], str | None]: +) -> tuple[ + list[click.Argument | click.Option | 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[click.Argument | click.Option | click.Parameter] = [] convertors = {} From 67d5923aa69291a3b580aa161c8f7eb348b9afe9 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Sun, 3 May 2026 10:50:43 -0400 Subject: [PATCH 13/16] Slightly more efficient testing --- tests/test_command_tree.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_command_tree.py b/tests/test_command_tree.py index a312d9e273..387a20cc39 100644 --- a/tests/test_command_tree.py +++ b/tests/test_command_tree.py @@ -63,12 +63,8 @@ def test_subcommands_help(args: list[str], expected: bool): 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 + assert bool(SUBCMD_FLAG in result.stdout) == expected + assert bool(SUBCMD_HELP in result.stdout) == expected def test_subcommands_top_tree(): From 68a96742772fbfb3d409d33c5410291c2184f3b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 11:37:43 +0000 Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index 3694a3855b..9f6aff5581 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1393,7 +1393,9 @@ def get_command_name(name: str) -> str: def get_params_convertors_ctx_param_name_from_function( callback: Callable[..., Any] | None, -) -> tuple[list[TyperArgument | TyperOption | click.Parameter], dict[str, Any], str | None]: +) -> 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 = {} From 054d2790862581b516755ba96ff0701c06fe9393 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Sat, 30 May 2026 07:47:26 -0400 Subject: [PATCH 15/16] Fix import issues --- typer/command_tree.py | 9 ++++----- typer/main.py | 8 ++++---- typer/rich_utils.py | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/typer/command_tree.py b/typer/command_tree.py index c5afccffc3..0d33656609 100644 --- a/typer/command_tree.py +++ b/typer/command_tree.py @@ -2,8 +2,7 @@ from gettext import gettext as _ from typing import Any -import click - +from . import _click from .core import DEFAULT_MARKUP_MODE, HAS_RICH from .models import ParamMeta from .params import Option @@ -36,8 +35,8 @@ def _commands_from_info( def show_command_tree( - ctx: click.Context, - param: click.Parameter, + ctx: _click.Context, + param: _click.Parameter, value: Any, ) -> Any: if not value or ctx.resilient_parsing: @@ -59,7 +58,7 @@ def show_command_tree( formatter.section(SUBCOMMAND_TITLE) formatter.write_dl(items) content = formatter.getvalue().rstrip("\n") - click.echo(content) + _click.echo(content) else: from . import rich_utils diff --git a/typer/main.py b/typer/main.py index 9f6aff5581..81ac315395 100644 --- a/typer/main.py +++ b/typer/main.py @@ -117,7 +117,7 @@ def get_install_completion_arguments() -> tuple[_click.Parameter, _click.Paramet return click_install_param, click_show_param -def get_command_tree_parameter() -> click.Parameter: +def get_command_tree_parameter() -> _click.Parameter: meta = get_command_tree_param_meta() param, _ = get_click_param(meta) return param @@ -1394,10 +1394,10 @@ def get_command_name(name: str) -> str: def get_params_convertors_ctx_param_name_from_function( callback: Callable[..., Any] | None, ) -> tuple[ - list[TyperArgument | TyperOption | click.Parameter], dict[str, Any], str | None + 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] = [] + # 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: diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 6f3d345b7f..440eeca175 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -716,7 +716,7 @@ def rich_format_subcommands( console.print(SUBCOMMAND_NOTE.format(style=STYLE_COMMANDS_TABLE_FIRST_COLUMN)) -def rich_format_error(self: click.ClickException) -> None: +def rich_format_error(self: _click.ClickException) -> None: """Print richly formatted click errors. Called by custom exception handler to print richly formatted click errors. From 4c8e59a6b4a3e5e0c5c1d33a02974dc2848c20f2 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Sat, 30 May 2026 10:07:20 -0400 Subject: [PATCH 16/16] Use typer classes instead of deprecated conversion to dictionary --- typer/command_tree.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/typer/command_tree.py b/typer/command_tree.py index 0d33656609..595063cb47 100644 --- a/typer/command_tree.py +++ b/typer/command_tree.py @@ -3,7 +3,7 @@ from typing import Any from . import _click -from .core import DEFAULT_MARKUP_MODE, HAS_RICH +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 @@ -12,24 +12,27 @@ SUBCOMMAND_TITLE = _("Sub-Commands") -def _commands_from_info( - info: dict[str, Any], indent_level: int +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 = info.get("commands", {}) + 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 + info.get("name", "unknown") + note - help = info.get("short_help") or info.get("help") or "" + 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.get("hidden", False): + if subcommand.hidden: continue - items.extend(_commands_from_info(subcommand, indent_level + 1)) + items.extend(_subcommand_items(subcommand, indent_level + 1)) return items @@ -42,14 +45,14 @@ def show_command_tree( 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 - + command = ctx.command + if not isinstance(command, TyperGroup): + return value # pragma: no cover items = [] - for subcommand in subcommands.values(): - if subcommand.get("hidden", False): + for subcommand in command.commands.values(): + if subcommand.hidden: continue - items.extend(_commands_from_info(subcommand, 0)) + items.extend(_subcommand_items(subcommand, 0)) if items: markup_mode = DEFAULT_MARKUP_MODE