Skip to content

Commit 29c028b

Browse files
committed
feat: add overview subcommand at help position #2
1 parent 0981a5a commit 29c028b

4 files changed

Lines changed: 193 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121

2222
### Added
2323

24+
- **`dirplot overview` command** — prints a human-readable summary of all
25+
commands, their arguments, options, and global options. Appears at position
26+
#2 in the help listing.
27+
2428
- **`dirplot hg` command** — replay Mercurial changeset history as an animated
2529
treemap, identical in interface to `dirplot git`. Supports `--animate`,
2630
`--max-commits`, `--last`, `--total-duration`, `--frame-duration`, `--fade-out`,

src/dirplot/_overview.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""_overview.py: Reusable overview command for Typer applications.
2+
3+
This module provides a plug-and-play `overview` command that prints a
4+
human-readable summary of any Typer application, including:
5+
- Application name and description
6+
- Global options (from @app.callback)
7+
- All commands with their arguments and options
8+
- Nested sub-commands (from add_typer) at any depth
9+
10+
Usage:
11+
from dirplot._overview import add_overview_command
12+
13+
add_overview_command(app) # call after defining all commands
14+
15+
Note:
16+
Call add_overview_command() AFTER registering all commands and sub-apps
17+
to ensure they appear in the overview output.
18+
"""
19+
20+
from typing import Any
21+
22+
import click
23+
import typer
24+
25+
26+
def add_overview_command(
27+
app: typer.Typer,
28+
name: str = "overview",
29+
help_text: str = (
30+
"Display an overview of all commands, global options, and"
31+
" command-specific options/arguments."
32+
),
33+
) -> None:
34+
"""
35+
Register an overview command on the given Typer application.
36+
37+
Args:
38+
app: The Typer application to add the overview command to.
39+
name: The name of the overview command (default: "overview").
40+
help_text: Help text for the overview command.
41+
"""
42+
43+
@app.command(name=name, help=help_text)
44+
def overview_command() -> None:
45+
_print_overview(app)
46+
47+
48+
def _print_overview(app: typer.Typer) -> None:
49+
"""Print the full application overview."""
50+
typer.echo("Application Overview")
51+
typer.echo("=" * 80)
52+
53+
# Build the Click command/group from the Typer app.
54+
click_group = typer.main.get_command(app)
55+
56+
# Extract app metadata from the Click group
57+
app_name = click_group.name or "(unnamed)"
58+
app_help = click_group.help or "(no description)"
59+
60+
typer.echo("\nApplication")
61+
typer.echo(f" Name : {app_name}")
62+
typer.echo(f" Help : {app_help}")
63+
64+
# Global options (from callback / group params)
65+
typer.echo("\nGlobal Options")
66+
global_params = getattr(click_group, "params", []) or []
67+
if global_params:
68+
for param in global_params:
69+
_print_param(param, is_global=True)
70+
else:
71+
typer.echo(" (none)")
72+
73+
# Commands (with recursive handling of nested groups)
74+
typer.echo("\nCommands")
75+
_print_commands(click_group, indent=1)
76+
77+
78+
def _print_commands(
79+
group: click.Command,
80+
indent: int = 1,
81+
max_depth: int = 10,
82+
seen: set[int] | None = None,
83+
) -> None:
84+
"""Recursively print commands, handling nested command groups."""
85+
if seen is None:
86+
seen = set()
87+
88+
# Cycle detection
89+
group_id = id(group)
90+
if group_id in seen:
91+
typer.echo(" " * indent + "(circular reference detected)")
92+
return
93+
seen.add(group_id)
94+
95+
# Depth limit
96+
if indent > max_depth:
97+
typer.echo(" " * indent + "(max depth reached)")
98+
return
99+
100+
commands = getattr(group, "commands", {}) or {}
101+
if not commands:
102+
typer.echo(" " * indent + "(no commands registered)")
103+
return
104+
105+
base_indent = " " * indent
106+
107+
for cmd_name in sorted(commands.keys()):
108+
cmd = commands[cmd_name]
109+
cmd_help = getattr(cmd, "help", None) or "(no description)"
110+
111+
# Check if this command is itself a group (nested sub-application)
112+
is_group = isinstance(cmd, click.Group)
113+
group_marker = " [group]" if is_group else ""
114+
115+
typer.echo(f"\n{base_indent}{cmd_name}{group_marker}")
116+
typer.echo(f"{base_indent} Help : {cmd_help}")
117+
118+
# Print group-level options if this is a nested group
119+
if is_group:
120+
group_params = getattr(cmd, "params", []) or []
121+
group_opts = [p for p in group_params if isinstance(p, click.Option)]
122+
if group_opts:
123+
typer.echo(f"{base_indent} Group Options:")
124+
for opt in group_opts:
125+
_print_param(opt, indent=indent + 2)
126+
127+
# Recursively print sub-commands
128+
typer.echo(f"{base_indent} Sub-commands:")
129+
_print_commands(cmd, indent=indent + 2, max_depth=max_depth, seen=seen)
130+
else:
131+
# Regular command: print its parameters
132+
params = getattr(cmd, "params", []) or []
133+
if not params:
134+
typer.echo(f"{base_indent} Parameters : (none)")
135+
continue
136+
137+
args = [p for p in params if isinstance(p, click.Argument)]
138+
opts = [p for p in params if isinstance(p, click.Option)]
139+
140+
if args:
141+
typer.echo(f"{base_indent} Arguments:")
142+
for arg in args:
143+
_print_param(arg, indent=indent + 2)
144+
145+
if opts:
146+
typer.echo(f"{base_indent} Options:")
147+
for opt in opts:
148+
_print_param(opt, indent=indent + 2)
149+
150+
151+
def _print_param(param: Any, is_global: bool = False, indent: int = 3) -> None:
152+
"""Helper to format and display a Click Parameter object."""
153+
base_indent = " " * indent
154+
prefix = "Global " if is_global else ""
155+
if isinstance(param, click.Option):
156+
names = ", ".join(param.opts) if getattr(param, "opts", None) else (param.name or "?")
157+
help_text = getattr(param, "help", None)
158+
else:
159+
# click.Argument has no .opts and no .help
160+
names = param.name or "?"
161+
help_text = None
162+
try:
163+
type_name = param.type.name if hasattr(param.type, "name") else str(param.type)
164+
except (AttributeError, TypeError):
165+
type_name = "unknown"
166+
default_str = (
167+
f" (default: {param.default!r})" if param.default is not None and not param.required else ""
168+
)
169+
required_str = " [required]" if param.required else ""
170+
171+
line = f"{base_indent}{names:<18} : {type_name}{default_str}{required_str}"
172+
if help_text:
173+
line += f" — {help_text}"
174+
175+
typer.echo(prefix + line)

src/dirplot/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2281,6 +2281,10 @@ def _info(msg: str) -> None:
22812281
display_window(buf, title=_display_title)
22822282

22832283

2284+
from dirplot._overview import add_overview_command # noqa: E402
2285+
2286+
add_overview_command(app)
2287+
22842288
# Reorder help output regardless of definition order.
2285-
_CMD_ORDER = ["demo", "termsize", "map", "git", "hg", "watch", "replay", "read-meta"]
2289+
_CMD_ORDER = ["demo", "overview", "termsize", "map", "git", "hg", "watch", "replay", "read-meta"]
22862290
app.registered_commands.sort(key=lambda c: _CMD_ORDER.index(c.name or ""))

tests/test_cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ def test_cli_termsize() -> None:
173173
assert "×" in result.output
174174

175175

176+
def test_overview() -> None:
177+
result = runner.invoke(app, ["overview"])
178+
assert result.exit_code == 0
179+
assert "Application Overview" in result.output
180+
assert "dirplot" in result.output
181+
assert "map" in result.output
182+
assert "git" in result.output
183+
184+
176185
def test_read_meta_png(sample_tree: Path, tmp_path: Path) -> None:
177186
output = tmp_path / "out.png"
178187
runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(output)])

0 commit comments

Comments
 (0)