Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add ability to show sub-commands #1115

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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()
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
103 changes: 103 additions & 0 deletions typer/command_tree.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading