Skip to content
Merged
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
22 changes: 11 additions & 11 deletions examples/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,21 +144,21 @@ def main():
print(" -> NestedCommand")
print(f" nested_flag = {cmd.subcommand.nested_flag}") # pyrefly: ignore[missing-attribute]
except Exception:
print(f" option = {cmd.option}")
print(f" flag = {cmd.flag}")
print(f" file = {cmd.file}")
print(f" directory = {cmd.directory}")
print(f" name = {cmd.name}")
print(f" age = {cmd.age}")
print(f" locations = {cmd.locations}")
print(f" nested_option = {cmd.nested_option}")
print(f" option = {cmd.option}")
print(f" flag = {cmd.flag}")
print(f" file = {cmd.file}")
print(f" directory = {cmd.directory}")
print(f" name = {cmd.name}")
print(f" age = {cmd.age}")
print(f" locations = {cmd.locations}")
print(f" nested_option = {cmd.nested_option}")

case AlternateCommand() as cmd:
print("-> AlternateCommand\n")
print(f" alternate_option = {cmd.alternate_option}")
print("-> AlternateCommand")
print(f" alternate_option = {cmd.alternate_option}")

case None:
print("-> No subcommand provided")
print(ctx.help())

# TODO: This is a bit messy as it is dynamically added to the context.
print(f"\nglobal_flag = {ctx.global_flag}") # pyrefly: ignore[missing-attribute]
Expand Down
108 changes: 108 additions & 0 deletions src/cranberry/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,111 @@ def __init__(self, command: Any | None, globals_: dict[str, Any]) -> None:
def __repr__(self) -> str:
attrs = {k: v for k, v in self.__dict__.items() if k != "command"}
return f"ParseContext(command={self.command!r}, globals={attrs!r})"

def help(self, command: Any | None = None) -> str:
"""
Return the formatted and stylised help message for the current context.

Works for the top-level app (no argument) and for a specific command
(pass either a command class or an instantiated command). When an
instantiated parent command that contains a subcommand is passed, the
help for the contained subcommand is returned (this mirrors the common
use-case of calling ctx.help(ctx.command) to get help for the active
subcommand).
"""
# Local imports to avoid circular import at module import time.
from cranberry import parser as _parser_module
from cranberry.style import resolve_style
from cranberry.registry import collect_fields
from cranberry.help_ import render_help, _cmd_name
from cranberry.errors import panic

if _parser_module._app_fn is None:
panic("No app entry-point registered")

app_meta = getattr(_parser_module._app_fn, "__cb_meta__", {})
app_name = app_meta.get("name", "app")
style = resolve_style(app_meta.get("style"))
footer_msg = app_meta.get("footer_message")
help_cfg = app_meta.get("help", {})
version_cfg = app_meta.get("version", {})

# Global fields (from @cb.globals)
global_fields: dict[str, Any] = {}
if _parser_module._globals_cls:
global_fields = collect_fields(_parser_module._globals_cls)

# Helper: find immediate parent command name for a target class
def _find_parent_name(
target: type, subs: list[type], parent_name: str | None = None
):
for sub in subs:
if sub is target:
return parent_name
child_subs = getattr(sub, "__cb_meta__", {}).get("subcommands", [])
res = _find_parent_name(target, child_subs, _cmd_name(sub))
if res is not None:
return res
return None

# Top-level help
if command is None:
command_name = None
command_fields: dict[str, Any] = {}
subcommands = app_meta.get("subcommands", [])
parent_command = None
description = app_meta.get("description")

return render_help(
app_name=app_name,
command_name=command_name,
description=description,
global_fields=global_fields,
command_fields=command_fields,
subcommands=subcommands,
has_help_flag=help_cfg.get("flag", True),
has_help_subcommand=help_cfg.get("subcommand", False),
has_version_flag=version_cfg.get("flag", True),
has_version_subcommand=version_cfg.get("subcommand", False),
footer_msg=footer_msg,
style=style,
parent_command=parent_command,
)

# If a command instance with a nested subcommand was passed, prefer the
# nested subcommand (this supports calling ctx.help(ctx.command) where
# ctx.command is a container with an active .subcommand).
if (
not isinstance(command, type)
and hasattr(command, "subcommand")
and getattr(command, "subcommand") is not None
):
target_cls = type(getattr(command, "subcommand"))
parent_command = _cmd_name(type(command))
else:
target_cls = command if isinstance(command, type) else type(command)
parent_command = _find_parent_name(
target_cls, app_meta.get("subcommands", [])
)

command_fields = collect_fields(target_cls)
cmd_meta = getattr(target_cls, "__cb_meta__", {})
subcommands = cmd_meta.get("subcommands", [])
command_name = _cmd_name(target_cls)
description = cmd_meta.get("description")

return render_help(
app_name=app_name,
command_name=command_name,
description=description,
global_fields=global_fields,
command_fields=command_fields,
subcommands=subcommands,
has_help_flag=help_cfg.get("flag", True),
has_help_subcommand=help_cfg.get("subcommand", False),
has_version_flag=version_cfg.get("flag", True),
has_version_subcommand=version_cfg.get("subcommand", False),
footer_msg=footer_msg,
style=style,
parent_command=parent_command,
)
48 changes: 48 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,51 @@ def test_repr_shows_command(self):
def test_repr_shows_globals(self):
ctx = ParseContext(command=None, globals_={"key": "val"})
assert "key" in repr(ctx)

def test_help_top_level_renders_usage_and_app_name(self):
import cranberry as cb

# Register a minimal app with a subcommand to ensure the help layout
@cb.command("sub")
class Sub:
pass

def main():
pass

# Apply decorators in a safe order
main = cb.app("myapp")(main)
main = cb.subcommand(Sub)(main)
main = cb.help(flag=True)(main)
main = cb.style("plain")(main)

ctx = cb.parse_args([])
out = ctx.help()
assert "Usage:" in out
assert "myapp" in out

def test_help_for_subcommand_includes_parent_and_child(self):
import cranberry as cb

@cb.command("leaf")
class Leaf:
name: str = cb.arg()

@cb.command("parent")
@cb.subcommand(Leaf)
class Parent:
pass

def main():
pass

main = cb.app("theapp")(main)
main = cb.subcommand(Parent)(main)
main = cb.help(flag=True)(main)
main = cb.style("plain")(main)

ctx = cb.parse_args(["parent", "leaf", "value"])
out = ctx.help(ctx.command)
assert "Usage:" in out
assert "parent" in out
assert "leaf" in out
1 change: 1 addition & 0 deletions tests/test_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ class Sub:
# Description should appear early in the output
assert result.index("CLI tool description") < result.index("Commands:")


# ---------------------------------------------------------------------------
# _strip_ansi
# ---------------------------------------------------------------------------
Expand Down
Loading