diff --git a/examples/example.py b/examples/example.py index 186e0fd..c320653 100644 --- a/examples/example.py +++ b/examples/example.py @@ -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] diff --git a/src/cranberry/context.py b/src/cranberry/context.py index 5f0c7cd..4b85718 100644 --- a/src/cranberry/context.py +++ b/src/cranberry/context.py @@ -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, + ) diff --git a/tests/test_context.py b/tests/test_context.py index 3184221..1f4fe7d 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -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 diff --git a/tests/test_help.py b/tests/test_help.py index bc4fef7..c5b6527 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -172,6 +172,7 @@ class Sub: # Description should appear early in the output assert result.index("CLI tool description") < result.index("Commands:") + # --------------------------------------------------------------------------- # _strip_ansi # ---------------------------------------------------------------------------