Skip to content
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()
204 changes: 204 additions & 0 deletions tests/test_command_tree.py
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions typer/command_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import sys
from gettext import gettext as _
from typing import Any, Dict, List, Tuple

import click

from .core import DEFAULT_MARKUP_MODE, HAS_RICH
from .models import ParamMeta
from .params import Option
from .utils import get_params_from_function

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 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