Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7f23704
Add ability to show sub-commands
rickwporter Jan 8, 2025
0ce09f1
Merge branch 'master' into sub-command-tree
rickwporter Jan 8, 2025
320dc61
Merge branch 'master' into sub-command-tree
rickwporter Jan 9, 2025
50fd088
Fix Literal typing issue for Python 3.7
rickwporter Jan 9, 2025
57688b2
Merge branch 'master' into sub-command-tree
rickwporter Feb 28, 2025
7ef2d7a
Merge branch 'master' into sub-command-tree
rickwporter Sep 20, 2025
8959509
Fix some import issues with rich
rickwporter Sep 22, 2025
ba96bf6
Merge branch 'master' into sub-command-tree
rickwporter Sep 22, 2025
5c8c4ac
Use lazy loading of rich_utils, and rich variables from core
rickwporter Sep 22, 2025
61a28ac
Merge branch 'master' into sub-command-tree
rickwporter Oct 11, 2025
7dfa8dd
Merge branch 'master' into sub-command-tree
rickwporter Oct 14, 2025
d5c78f1
Merge branch 'master' into sub-command-tree
rickwporter Oct 20, 2025
3d0a5bf
Merge branch 'master' into sub-command-tree
rickwporter Nov 29, 2025
9b51fb9
Merge branch 'master' into sub-command-tree
rickwporter Dec 25, 2025
5936111
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Dec 25, 2025
132dea9
Merge branch 'master' into sub-command-tree
rickwporter Dec 26, 2025
6a04f59
🎨 Auto format
pre-commit-ci-lite[bot] Dec 26, 2025
3d28d55
Use latest builtins
rickwporter Dec 26, 2025
5eed30d
Merge branch 'master' into sub-command-tree
rickwporter Jan 14, 2026
b966e6c
Merge branch 'master' into sub-command-tree
rickwporter Feb 8, 2026
9a6094a
Merge branch 'master' into sub-command-tree
rickwporter Feb 10, 2026
ef8929a
🎨 Auto format
pre-commit-ci-lite[bot] Feb 10, 2026
6168377
Merge branch 'master' into sub-command-tree
rickwporter Mar 14, 2026
38321aa
Merge branch 'master' into sub-command-tree
rickwporter Apr 11, 2026
6842f5d
Merge branch 'master' into sub-command-tree
rickwporter Apr 17, 2026
87420a8
Merge branch 'master' into sub-command-tree
rickwporter May 1, 2026
4840b4b
Fix indentation
rickwporter May 2, 2026
4ac4815
Ignore issue with overly strict type checking
rickwporter May 3, 2026
4908daf
Allow get_params_convertors_ctx_param_name_from_function() to return …
rickwporter May 3, 2026
a7be308
🎨 Auto format
pre-commit-ci-lite[bot] May 3, 2026
67d5923
Slightly more efficient testing
rickwporter May 3, 2026
bdc9612
Merge branch 'master' into sub-command-tree
rickwporter May 30, 2026
68a9674
🎨 Auto format
pre-commit-ci-lite[bot] May 30, 2026
054d279
Fix import issues
rickwporter May 30, 2026
4c8e59a
Use typer classes instead of deprecated conversion to dictionary
rickwporter May 30, 2026
069fd68
Merge branch 'master' into sub-command-tree
rickwporter May 30, 2026
76baac4
Merge branch 'master' into sub-command-tree
rickwporter Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions tests/assets/subcommand_tree.py
Original file line number Diff line number Diff line change
@@ -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()
199 changes: 199 additions & 0 deletions tests/test_command_tree.py
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions typer/command_tree.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading