diff --git a/.gitignore b/.gitignore index e2b54de..f70b4ca 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ dist build/ _build/ docs/html/ -eggs \ No newline at end of file +eggs +.github/ +.vscode/ \ No newline at end of file diff --git a/README.rst b/README.rst index 6fcd8cd..d05cb4c 100644 --- a/README.rst +++ b/README.rst @@ -15,6 +15,7 @@ Features - Asynchronous and parallel tasks using asyncio - Easy function to command interface +- Nested command groups for shorter, semantic command paths - Out of box functionality - Intuitive usability diff --git a/docs/pages/documentation.rst b/docs/pages/documentation.rst index c1c309d..c6cfbf6 100644 --- a/docs/pages/documentation.rst +++ b/docs/pages/documentation.rst @@ -17,4 +17,10 @@ Command ------- .. automodule:: cmdcraft.command + :members: + +CommandGroup +------------ + +.. automodule:: cmdcraft.group :members: \ No newline at end of file diff --git a/docs/pages/howto/crafting.rst b/docs/pages/howto/crafting.rst index 4f6277f..7a4c2b1 100644 --- a/docs/pages/howto/crafting.rst +++ b/docs/pages/howto/crafting.rst @@ -15,5 +15,77 @@ It is highly recommended to use annotations in the callable parameters, so the p arguments can be cast into said types. This will help you control and validate the input your user inputs. -Custom types ------------- \ No newline at end of file +Command Grouping +================ + +Commands do not need to stay in a flat namespace. If your application has several +commands that would otherwise need long aliases such as ``project_status`` and +``project_config_set``, you may register them under a semantic path instead. + +Both ``register_group()`` and ``register_command()`` treat whitespace as a path +separator: + +- ``register_group("project config")`` means "register the group ``config`` under + the group ``project``". +- ``register_command(set_value, alias="project config set")`` means "register the + command ``set`` under the group path ``project -> config``". +- Registering the same group path more than once reuses the existing group, + which lets sibling groups share the same parent naturally. +- Registering a command or group on a path that is already occupied raises + ``ValueError``. + +.. code:: python + + import asyncio + from cmdcraft import Prompter + + async def start() -> None: + print("project started") + + async def status() -> None: + print("project status shown") + + async def show() -> None: + print("project configuration shown") + + async def set_value(key: str, value: str) -> None: + print(f"project configuration updated: {key}={value}") + + async def list_profiles() -> None: + print("profiles: dev, test, prod") + + async def main() -> None: + prompt = Prompter() + + project = prompt.register_group("project", doc="Project commands.") + project.register_command(start) + project.register_command(status) + + config = prompt.register_group( + "project config", + doc="Project configuration commands.", + ) + config.register_command(show) + prompt.register_command(set_value, alias="project config set") + + profiles = prompt.register_group( + "project profile", + doc="Project profile commands.", + ) + profiles.register_command(list_profiles, alias="list") + + await prompt.run() + + asyncio.run(main()) + +This produces grouped commands such as ``project start``, ``project status``, +``project config show``, ``project config set theme dark`` and +``project profile list``. Re-registering grouped paths such as +``project config`` and ``project profile`` reuses the same ``project`` parent +group automatically. + +The group path can be written either explicitly by chaining +``register_group()`` calls or with a spaced alias such as +``prompt.register_group("project config")``. + +The runnable example in ``examples/group_commands.py`` demonstrates both styles. diff --git a/examples/group_commands.py b/examples/group_commands.py new file mode 100644 index 0000000..1b420c4 --- /dev/null +++ b/examples/group_commands.py @@ -0,0 +1,64 @@ +# ***************************************************************************** +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# ***************************************************************************** +"""cmdcraft example using grouped commands.""" + +import asyncio + +from cmdcraft import Prompter + + +async def start() -> None: + """Start the motor.""" + print("motor started") + + +async def stop() -> None: + """Stop the motor.""" + print("motor stopped") + + +async def home() -> None: + """Home the motor axis.""" + print("motor axis homed") + + +async def move(position: float) -> None: + """Move the motor axis to a position. + + Args: + position (float): Target axis position. + + Returns: + None: This coroutine does not return a value. + + """ + print(f"moving motor axis to {position}") + + +async def main() -> None: + """Run the grouped command example. + + Returns: + None: This coroutine does not return a value. + + """ + prompt = Prompter() + + motor = prompt.register_group("motor", doc="Motor related commands.") + motor.register_command(start) + motor.register_command(stop) + + axis = prompt.register_group("motor axis", doc="Motor axis commands.") + axis.register_command(home) + prompt.register_command(move, alias="motor axis move") + + await prompt.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tmp.py b/examples/tmp.py deleted file mode 100644 index 5e56eca..0000000 --- a/examples/tmp.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""cmdcraft example using Prompter.""" - -import asyncio - -from cmdcraft import Prompter -from enum import Enum - -class EnumTest(Enum): - A = 1 - B = 2 - - -async def test_input(s: str, a: EnumTest, prompt: str = 'a') -> None: - print(f"The input is: {prompt}") - -def test() -> list[str]: - r = ("a", "b", "c") - return r - - -async def main(): - prompt = Prompter() - cmd = prompt.register_command(test_input) - cmd.parameter("prompt").set_dynamic_options(test) - cmd.parameter("s").set_dynamic_options(test) - await prompt.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/cmdcraft/__init__.py b/src/cmdcraft/__init__.py index 9286094..308af02 100644 --- a/src/cmdcraft/__init__.py +++ b/src/cmdcraft/__init__.py @@ -1,15 +1,23 @@ -#!/usr/bin/env python3 -"""cmdcraft module.""" +# ***************************************************************************** +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# ***************************************************************************** +"""cmdcraft package exports.""" from __future__ import annotations from .base import BasePrompter +from .group import CommandGroup from .prompter import Prompter -__version__ = "0.0.7" +__version__ = "0.0.8" __all__ = [ "BasePrompter", + "CommandGroup", "Prompter", "__version__", ] diff --git a/src/cmdcraft/base.py b/src/cmdcraft/base.py index 2816bfa..8ce9127 100644 --- a/src/cmdcraft/base.py +++ b/src/cmdcraft/base.py @@ -10,9 +10,11 @@ import asyncio import os from abc import ABCMeta, abstractmethod +from collections.abc import Callable from inspect import cleandoc from .command import Command +from .group import CommandGroup, split_command_path from .input import Input @@ -25,7 +27,8 @@ class BasePrompter(metaclass=ABCMeta): def __init__(self) -> None: """Command Set initializer.""" - self._commands: dict[str, Command] = {} + self._root_group = CommandGroup("root") + self._commands: dict[str, Command | CommandGroup] = self._root_group.commands # Register default commands self.register_command(self.clear) self.register_command(self.history) @@ -35,14 +38,14 @@ def __init__(self) -> None: self.register_command(self.wait) # Register help command - help = self.register_command(self.help) + help_command = self.register_command(self.help) def get_funcs() -> list[str]: - return list(self._commands) + return self._root_group.command_paths(include_groups=True) - help.parameter("command").set_dynamic_options(get_funcs) + help_command.parameter("command").set_dynamic_options(get_funcs) - self._history = [] + self._history: list[str] = [] self._is_running: bool = False self._is_init: bool = False self._shutdown_requested: bool = False @@ -89,29 +92,74 @@ def request_shutdown(self) -> None: "to finish. Press Ctrl-C again to force exit." ) - def register_command(self, command: callable, alias: str | None = None) -> Command: + def register_command( + self, + command: Callable[..., object], + alias: str | None = None, + ) -> Command: """Register a command into the interpreter. Args: - command (callable): Callable. + command (Callable[..., object]): Callable. alias (str | None, optional): Command alias. Defaults to None. + Returns: + Command: Registered command wrapper. + + """ + return self._root_group.register_command(command, alias) + + def register_group( + self, name: str, alias: str | None = None, doc: str | None = None + ) -> CommandGroup: + """Register a command group into the interpreter. + + Args: + name (str): Group name. + alias (str | None, optional): Group alias or path. Defaults to None. + doc (str | None, optional): Group documentation. Defaults to None. + + Returns: + CommandGroup: Registered command group. + """ - m = Command(command, alias) - self._commands[m.alias] = m - m.process() - return m + return self._root_group.register_group(name, alias=alias, doc=doc) @property - def commands(self) -> dict: - """Return the available commands. + def commands(self) -> dict[str, Command | CommandGroup]: + """Return the available top-level commands and groups. Returns: - dict: Commands dictionary. + dict[str, Command | CommandGroup]: Top-level command and group + dictionary. """ return self._commands + def _resolve_command_path( + self, tokens: list[str] + ) -> tuple[Command | CommandGroup | None, int]: + """Resolve a command path from the current registry.""" + return self._root_group.resolve(tokens) + + def _command_paths(self, include_groups: bool = False) -> list[str]: + """Return all registered command paths.""" + return self._root_group.command_paths(include_groups=include_groups) + + def _format_group_help(self, group_path: str, group: CommandGroup) -> str: + """Build help text for a command group.""" + lines = [] + if group.__doc__: + lines.append(cleandoc(group.__doc__)) + else: + lines.append(f"Command group: {group_path}") + + lines.append("") + lines.append("Available commands:") + for name in group.commands: + lines.append(f"- {name}") + return "\n".join(lines) + async def interpret(self, cmdline: str) -> None: """Interpret user input. @@ -121,20 +169,40 @@ async def interpret(self, cmdline: str) -> None: Args: cmdline (str): Input command as single string line. + Returns: + None: This coroutine does not return a value. + """ + command_path = "help" try: - input = Input(cmdline) - input.process() - if len(input.tokens) < 1: + prompt_input = Input(cmdline) + prompt_input.process() + if len(prompt_input.tokens) < 1: return - cmd = self._commands.get(input.tokens[0], None) + command_path = prompt_input.tokens[0] + cmd, consumed = self._resolve_command_path(prompt_input.tokens) if cmd is None: - self.output(f"Unknown command: {input.tokens[0]}") + self.output(f"Unknown command: {prompt_input.tokens[0]}") await self.help() return - await cmd.eval(*input.tokens[1:]) + + command_path = " ".join(prompt_input.tokens[:consumed]) + if isinstance(cmd, CommandGroup): + if consumed == len(prompt_input.tokens): + await self.help(command_path) + else: + unknown = " ".join(prompt_input.tokens[: consumed + 1]) + self.output(f"Unknown command: {unknown}") + await self.help(command_path) + return + + args = prompt_input.tokens[consumed:] + if cmd.alias == "help" and consumed == 1 and len(args) > 1: + args = [" ".join(args)] + + await cmd.eval(*args) except TypeError as e: - await self.help(input.tokens[0]) + await self.help(command_path) self.output(e) except Exception as e: self.output(e) @@ -145,13 +213,30 @@ async def help(self, command: str = "help") -> None: The interpreter receives instructions from the standard input (stdin) to dynamically execute operations on running services. - For further help, type the command `help [command]`. + For further help, type the command `help [command [subcommand]]`. + + Args: + command (str, optional): Command path to describe. Defaults to + "help". + + Returns: + None: This coroutine does not return a value. + """ - cmd = self._commands.get(command, None) - if cmd: + help_text = cleandoc(self.help.__doc__ or "").split("\n\nArgs:\n", 1)[0] + tokens = split_command_path(command) if command.strip() else ["help"] + if tokens == ["help"]: + self.output(help_text) + self.output("") + return + + cmd, consumed = self._resolve_command_path(tokens) + if isinstance(cmd, CommandGroup) and consumed == len(tokens): + self.output(self._format_group_help(command, cmd)) + elif isinstance(cmd, Command): self.output(cleandoc(cmd.__doc__)) else: - self.output(cleandoc(self.help.__doc__)) + self.output(help_text) self.output("") async def clear(self) -> None: diff --git a/src/cmdcraft/group.py b/src/cmdcraft/group.py new file mode 100644 index 0000000..d1d0557 --- /dev/null +++ b/src/cmdcraft/group.py @@ -0,0 +1,270 @@ +# ***************************************************************************** +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# ***************************************************************************** +"""Command grouping utilities.""" + +from __future__ import annotations + +from collections.abc import Callable + +from .command import Command + + +def split_command_path(path: str) -> list[str]: + """Split a command path into its segments. + + Args: + path (str): Command path separated by spaces. + + Returns: + list[str]: Non-empty path segments. + + Raises: + ValueError: If the provided path is empty. + + """ + parts = path.split() + if not parts: + raise ValueError("Command paths cannot be empty.") + return parts + + +def _leaf_group_name(name: str, alias: str | None) -> str: + """Return the group name that should be used at the leaf node. + + Args: + name (str): Requested group name. + alias (str | None): Requested group alias. + + Returns: + str: Leaf group name. + + """ + if alias is None: + return split_command_path(name)[-1] + return name + + +def _raise_name_conflict(path: str) -> None: + """Raise a standardized registration conflict error. + + Args: + path (str): Conflicting path. + + Returns: + None: This helper does not return a value. + + Raises: + ValueError: Always raised for conflicting registrations. + + """ + raise ValueError(f"A command or group is already registered at '{path}'.") + + +class CommandGroup: + """Namespace for commands and nested groups.""" + + def __init__( + self, + name: str, + alias: str | None = None, + doc: str | None = None, + ) -> None: + """Build a command group. + + Args: + name (str): Group name. + alias (str | None, optional): Group alias. Defaults to None. + doc (str | None, optional): Group documentation. Defaults to None. + + Returns: + None: This constructor does not return a value. + + """ + self._name = name + self._alias = alias if alias is not None else name + self._doc = doc + self._commands: dict[str, Command | CommandGroup] = {} + + @property + def name(self) -> str: + """Return the group name. + + Returns: + str: The registered group name. + + """ + return self._name + + @property + def alias(self) -> str: + """Return the group alias. + + Returns: + str: The alias used in the prompt. + + """ + return self._alias + + @property + def __doc__(self) -> str: + """Return the group documentation. + + Returns: + str: Group documentation text. + + """ + return self._doc or "" + + @property + def commands(self) -> dict[str, Command | CommandGroup]: + """Return child commands and groups. + + Returns: + dict[str, Command | CommandGroup]: Registered child nodes. + + """ + return self._commands + + def register_group( + self, + name: str, + alias: str | None = None, + doc: str | None = None, + ) -> CommandGroup: + """Register a child group and return it. + + Args: + name (str): Group name. + alias (str | None, optional): Group alias or path. Defaults to None. + doc (str | None, optional): Group documentation. Defaults to None. + + Returns: + CommandGroup: The registered or reused child group. + + Raises: + ValueError: If the group path conflicts with an existing command. + + """ + path = split_command_path(alias if alias is not None else name) + if len(path) == 1: + current = self._commands.get(path[0]) + if isinstance(current, CommandGroup): + if doc is not None: + current._doc = doc + return current + if isinstance(current, Command): + _raise_name_conflict(path[0]) + + group = CommandGroup( + _leaf_group_name(name, alias), + alias=path[0], + doc=doc, + ) + self._commands[group.alias] = group + return group + + parent = self.register_group(path[0]) + return parent.register_group( + _leaf_group_name(name, alias), + alias=" ".join(path[1:]), + doc=doc, + ) + + def register_command( + self, + command: Callable[..., object], + alias: str | None = None, + ) -> Command: + """Register a command into this group. + + Args: + command (Callable[..., object]): Command callback. + alias (str | None, optional): Command alias or path. Defaults to None. + + Returns: + Command: The registered command wrapper. + + Raises: + ValueError: If the command path conflicts with an existing command + or group. + + """ + path = split_command_path(alias if alias is not None else command.__name__) + if len(path) == 1: + current = self._commands.get(path[0]) + if current is not None: + _raise_name_conflict(path[0]) + + cmd = Command(command, path[0]) + self._commands[cmd.alias] = cmd + cmd.process() + return cmd + + group = self.register_group(path[0]) + return group.register_command(command, alias=" ".join(path[1:])) + + def resolve( + self, + tokens: list[str], + ) -> tuple[Command | CommandGroup | None, int]: + """Resolve a token list against this group tree. + + Args: + tokens (list[str]): Tokenized prompt input. + + Returns: + tuple[Command | CommandGroup | None, int]: The resolved node and the + number of consumed tokens. + + """ + current: CommandGroup = self + node: Command | CommandGroup | None = None + consumed = 0 + + for token in tokens: + node = current.commands.get(token) + if node is None: + break + + consumed += 1 + if isinstance(node, CommandGroup): + current = node + continue + + return node, consumed + + return node, consumed + + def command_paths( + self, + prefix: tuple[str, ...] = (), + include_groups: bool = False, + ) -> list[str]: + """Return registered command paths. + + Args: + prefix (tuple[str, ...], optional): Current prefix path. Defaults to (). + include_groups (bool, optional): Include group paths in the result. + Defaults to False. + + Returns: + list[str]: Registered command paths. + + """ + paths: list[str] = [] + for name, node in self._commands.items(): + current = (*prefix, name) + if isinstance(node, CommandGroup): + if include_groups: + paths.append(" ".join(current)) + paths.extend(node.command_paths(current, include_groups=include_groups)) + continue + + paths.append(" ".join(current)) + + return paths diff --git a/src/cmdcraft/prompter.py b/src/cmdcraft/prompter.py index 4c92cb1..03c9e6c 100644 --- a/src/cmdcraft/prompter.py +++ b/src/cmdcraft/prompter.py @@ -1,5 +1,11 @@ -#!/usr/bin/env python3 -"""Prompt Prompter.""" +# ***************************************************************************** +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# ***************************************************************************** +"""Prompt prompter implementation.""" from __future__ import annotations @@ -10,28 +16,55 @@ from cmdcraft import BasePrompter +from .command import Command from .completer import CommandCompleter +from .group import CommandGroup class Prompter(BasePrompter): - """Prompt Prompter class.""" + """Prompt-toolkit powered prompter.""" def __init__(self) -> None: - """Construct the interpreter object.""" + """Construct the interpreter object. + + Returns: + None: This constructor does not return a value. + + """ super().__init__() self._session = PromptSession() self._previous_sigint_handler = None async def init(self) -> None: - """Init the interpreter object.""" + """Initialize the interpreter object. + + Returns: + None: This coroutine does not return a value. + + """ await super().init() - def completer(self) -> None: - """Process interpreter completer.""" - cmds = {} - for name, cmd in self._commands.items(): - cmds[name] = CommandCompleter(cmd) - return NestedCompleter(cmds) + def _completion_tree( + self, + commands: dict[str, Command | CommandGroup], + ) -> dict[str, object]: + """Build the nested completion tree for commands and groups.""" + items: dict[str, object] = {} + for name, cmd in commands.items(): + if isinstance(cmd, CommandGroup): + items[name] = self._completion_tree(cmd.commands) + else: + items[name] = CommandCompleter(cmd) + return items + + def completer(self) -> NestedCompleter: + """Process the interpreter completer. + + Returns: + NestedCompleter: Prompt completer for commands, groups, and parameters. + + """ + return NestedCompleter.from_nested_dict(self._completion_tree(self._commands)) def _has_active_prompt(self) -> bool: """Return whether a prompt_toolkit application is running.""" @@ -63,7 +96,12 @@ def _handle_runtime_interrupt(self) -> None: self.request_shutdown() async def run(self) -> None: - """Run Prompter main loop.""" + """Run the prompter main loop. + + Returns: + None: This coroutine does not return a value. + + """ await super().run() self._is_running = True self._previous_sigint_handler = signal.getsignal(signal.SIGINT) @@ -92,5 +130,13 @@ async def run(self) -> None: self._previous_sigint_handler = None def output(self, *args) -> None: - """Output command.""" + """Output prompt results. + + Args: + *args: Values to print. + + Returns: + None: This method does not return a value. + + """ print(*args) diff --git a/test/test_base.py b/test/test_base.py index 47ecea9..a868d01 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -10,7 +10,10 @@ import asyncio from enum import Enum +import pytest + from cmdcraft.base import BasePrompter +from cmdcraft.group import CommandGroup class _DummyPrompter(BasePrompter): @@ -88,3 +91,158 @@ async def test_input(required: str) -> None: assert prompt.outputs[0] == "Specific command help." assert prompt.outputs[1] == "" assert "missing 1 required positional argument" in prompt.outputs[2] + + +def test_interpret_grouped_command_alias_path(): + """Test grouped command aliases resolve as command paths.""" + + async def motor_start() -> None: + prompt.output("motor started") + + prompt = _DummyPrompter() + prompt.register_command(motor_start, alias="motor start") + + asyncio.run(prompt.interpret("motor start")) + + assert prompt.outputs == ["motor started"] + + +def test_interpret_nested_registered_groups(): + """Test nested command groups resolve commands.""" + + async def start() -> None: + prompt.output("axis started") + + prompt = _DummyPrompter() + axis = prompt.register_group("motor axis") + axis.register_command(start) + + asyncio.run(prompt.interpret("motor axis start")) + + assert prompt.outputs == ["axis started"] + + +def test_grouped_command_type_error_shows_nested_command_help(): + """Test grouped command failures show the nested command help.""" + + async def motor_start(speed: str) -> None: + """Motor start help.""" + return None + + prompt = _DummyPrompter() + prompt.register_command(motor_start, alias="motor start") + + asyncio.run(prompt.interpret("motor start")) + + assert prompt.outputs[0] == "Motor start help." + assert prompt.outputs[1] == "" + assert "missing 1 required positional argument" in prompt.outputs[2] + + +def test_help_accepts_unquoted_grouped_command_path(): + """Test help accepts grouped command paths without quoting.""" + + async def motor_start() -> None: + """Motor start help.""" + return None + + prompt = _DummyPrompter() + prompt.register_command(motor_start, alias="motor start") + + asyncio.run(prompt.interpret("help motor start")) + + assert prompt.outputs == ["Motor start help.", ""] + + +def test_help_command_hides_api_doc_sections(): + """Test interpreter help does not expose API doc sections.""" + prompt = _DummyPrompter() + + asyncio.run(prompt.interpret("help")) + + assert prompt.outputs[0].startswith("Show Cmdcraft interpreter help.") + assert "Args:" not in prompt.outputs[0] + assert "Returns:" not in prompt.outputs[0] + assert prompt.outputs[1] == "" + + +def test_register_group_path_uses_leaf_group_name(): + """Test grouped paths register the leaf group at the leaf level.""" + prompt = _DummyPrompter() + + axis = prompt.register_group("motor axis") + motor = prompt.commands["motor"] + + assert isinstance(motor, CommandGroup) + assert axis.name == "axis" + assert axis.alias == "axis" + assert motor.commands["axis"] is axis + + +def test_register_command_path_uses_leaf_command_alias(): + """Test grouped command paths register the command at the leaf level.""" + + async def command_input() -> None: + return None + + prompt = _DummyPrompter() + command = prompt.register_command(command_input, alias="motor axis home") + motor = prompt.commands["motor"] + axis = motor.commands["axis"] + + assert isinstance(motor, CommandGroup) + assert isinstance(axis, CommandGroup) + assert command.alias == "home" + assert axis.commands["home"] is command + + +def test_register_group_paths_merge_same_parent_group(): + """Test sibling group paths reuse the same parent group.""" + prompt = _DummyPrompter() + + axis = prompt.register_group("motor axis") + sensor = prompt.register_group("motor sensor") + motor = prompt.commands["motor"] + + assert isinstance(motor, CommandGroup) + assert motor.commands["axis"] is axis + assert motor.commands["sensor"] is sensor + + +def test_register_command_raises_for_duplicate_command_name(): + """Test duplicate command registrations raise an exception.""" + + async def start() -> None: + return None + + prompt = _DummyPrompter() + prompt.register_command(start, alias="motor start") + + with pytest.raises(ValueError, match="already registered"): + prompt.register_command(start, alias="motor start") + + +def test_register_command_raises_for_existing_group_name(): + """Test command registrations cannot replace existing groups.""" + + async def axis() -> None: + return None + + prompt = _DummyPrompter() + prompt.register_group("motor axis") + + with pytest.raises(ValueError, match="already registered"): + prompt.register_command(axis, alias="motor axis") + + +def test_register_group_raises_for_existing_command_name(): + """Test group registrations cannot replace existing commands.""" + + async def axis() -> None: + return None + + prompt = _DummyPrompter() + prompt.register_command(axis, alias="motor axis") + + with pytest.raises(ValueError, match="already registered"): + prompt.register_group("motor axis") diff --git a/test/test_prompter.py b/test/test_prompter.py index c8e0a61..684188f 100644 --- a/test/test_prompter.py +++ b/test/test_prompter.py @@ -10,6 +10,8 @@ import asyncio import pytest +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.document import Document from cmdcraft.prompter import Prompter @@ -140,3 +142,26 @@ async def scenario() -> None: ) asyncio.run(scenario()) + + +def test_completer_supports_grouped_commands(): + """Test prompt completion for grouped command paths.""" + + async def motor_start() -> None: + return None + + prompt = _DummyPrompter() + prompt.register_command(motor_start, alias="motor start") + completer = prompt.completer() + + root = [ + completion.text + for completion in completer.get_completions(Document("mo"), CompleteEvent()) + ] + nested = [ + completion.text + for completion in completer.get_completions(Document("motor "), CompleteEvent()) + ] + + assert root == ["motor"] + assert nested == ["start"]