diff --git a/cyclopts/core.py b/cyclopts/core.py index c689072d..9a856baa 100644 --- a/cyclopts/core.py +++ b/cyclopts/core.py @@ -136,14 +136,16 @@ def _validate_default_command(x): return x -def _combined_meta_command_mapping(app: Optional["App"], recurse_meta=True) -> dict[str, "App"]: +def _combined_meta_command_mapping( + app: Optional["App"], recurse_meta=True, recurse_parent_meta=True +) -> dict[str, "App"]: """Return a copied and combined mapping containing app and meta-app commands.""" if app is None: return {} command_mapping = copy(app._commands) if recurse_meta: command_mapping.update(_combined_meta_command_mapping(app._meta)) - if app._meta_parent: + if recurse_parent_meta and app._meta_parent: command_mapping.update(_combined_meta_command_mapping(app._meta_parent, recurse_meta=False)) return command_mapping @@ -581,6 +583,8 @@ def meta(self) -> "App": def parse_commands( self, tokens: Union[None, str, Iterable[str]] = None, + *, + include_parent_meta=True, ) -> tuple[tuple[str, ...], tuple["App", ...], list[str]]: """Extract out the command tokens from a command. @@ -608,7 +612,7 @@ def parse_commands( apps: list[App] = [app] unused_tokens = tokens - command_mapping = _combined_meta_command_mapping(app) + command_mapping = _combined_meta_command_mapping(app, recurse_parent_meta=include_parent_meta) for i, token in enumerate(tokens): try: @@ -618,7 +622,7 @@ def parse_commands( except KeyError: break command_chain.append(token) - command_mapping = _combined_meta_command_mapping(app) + command_mapping = _combined_meta_command_mapping(app, recurse_parent_meta=include_parent_meta) return tuple(command_chain), tuple(apps), unused_tokens @@ -898,7 +902,7 @@ def _parse_known_args( meta_parent = self - command_chain, apps, unused_tokens = self.parse_commands(tokens) + command_chain, apps, unused_tokens = self.parse_commands(tokens, include_parent_meta=False) command_app = apps[-1] ignored: dict[str, Any] = {} diff --git a/tests/test_meta.py b/tests/test_meta.py index 9d7a4089..aa06d75b 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -1,8 +1,9 @@ +from textwrap import dedent from typing import Annotated import pytest -from cyclopts import Parameter +from cyclopts import App, Parameter @pytest.mark.parametrize( @@ -31,3 +32,121 @@ def meta(*tokens: Annotated[str, Parameter(allow_leading_hyphen=True)], meta_fla def test_meta_app_config_inheritance(app): app.config = ("foo", "bar") assert app.meta.config == ("foo", "bar") + + +@pytest.fixture +def queue(): + return [] + + +@pytest.fixture +def nested_meta_app(queue, console): + subapp = App(console=console) + + @subapp.meta.default + def subapp_meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]) -> None: + """This is subapp's help.""" + queue.append("subapp meta") + subapp(tokens) + + @subapp.command + def foo(value: int) -> None: + """Subapp foo help string. + + Parameters + ---------- + value: int + The value a user inputted. + """ + queue.append(f"subapp foo body {value}") + + app = App(name="test_app", console=console) + app.command(subapp.meta, name="subapp") + + @app.meta.default + def meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): + queue.append("root meta") + app(tokens) + + return app + + +def test_meta_app_nested_root_help(nested_meta_app, console, queue): + with console.capture() as capture: + nested_meta_app.meta(["--help"]) + + actual = capture.get() + + expected = dedent( + """\ + Usage: test_app COMMAND + + ╭─ Commands ─────────────────────────────────────────────────────────╮ + │ subapp This is subapp's help. │ + │ --help -h Display this message and exit. │ + │ --version Display application version. │ + ╰────────────────────────────────────────────────────────────────────╯ + """ + ) + + assert actual == expected + assert not queue + + +def test_meta_app_nested_subapp_help(nested_meta_app, console, queue): + with console.capture() as capture: + nested_meta_app.meta(["subapp", "--help"]) + + actual = capture.get() + + expected = dedent( + """\ + Usage: test_app subapp COMMAND [ARGS] + + This is subapp's help. + + ╭─ Commands ─────────────────────────────────────────────────────────╮ + │ foo Subapp foo help string. │ + │ --help -h Display this message and exit. │ + │ --version Display application version. │ + ╰────────────────────────────────────────────────────────────────────╯ + """ + ) + + assert actual == expected + assert not queue + + +def test_meta_app_nested_subapp_foo_help(nested_meta_app, console, queue): + with console.capture() as capture: + nested_meta_app.meta(["subapp", "foo", "--help"]) + + actual = capture.get() + + expected = dedent( + """\ + Usage: test_app subapp foo [ARGS] [OPTIONS] + + Subapp foo help string. + + ╭─ Parameters ───────────────────────────────────────────────────────╮ + │ * VALUE --value The value a user inputted. [required] │ + ╰────────────────────────────────────────────────────────────────────╯ + """ + ) + + assert actual == expected + assert not queue + + +@pytest.mark.parametrize( + "cmd_str,expected", + [ + ("", ["root meta"]), + ("subapp", ["root meta", "subapp meta"]), + ("subapp foo 5", ["root meta", "subapp meta", "subapp foo body 5"]), + ], +) +def test_meta_app_nested_exec(nested_meta_app, queue, cmd_str, expected): + nested_meta_app.meta(cmd_str) + assert queue == expected