diff options
-rw-r--r-- | cmd2/cmd2.py | 32 | ||||
-rw-r--r-- | cmd2/command_definition.py | 29 | ||||
-rw-r--r-- | examples/modular_commands/commandset_basic.py | 17 | ||||
-rw-r--r-- | tests/test_commandset.py | 146 |
4 files changed, 188 insertions, 36 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ef273d15..310ad32f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,7 +46,7 @@ from typing import Any, AnyStr, Callable, Dict, Iterable, List, Mapping, Optiona from . import ansi, constants, plugin, utils from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer -from .command_definition import _UNBOUND_COMMANDS, CommandSet, _partial_passthru +from .command_definition import _REGISTERED_COMMANDS, CommandSet, _partial_passthru from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX from .decorators import with_argparser from .exceptions import Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, SkipPostcommandHooks @@ -403,8 +403,8 @@ class Cmd(cmd.Cmd): """ # start by loading registered functions as commands - for cmd_name, cmd_func, cmd_completer, cmd_help in _UNBOUND_COMMANDS: - self.install_command_function(cmd_name, cmd_func, cmd_completer, cmd_help) + for cmd_name in _REGISTERED_COMMANDS.keys(): + self.install_registered_command(cmd_name) # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor all_commandset_defs = CommandSet.__subclasses__() @@ -492,6 +492,28 @@ class Cmd(cmd.Cmd): cmdset.on_unregister(self) self._installed_command_sets.remove(cmdset) + def install_registered_command(self, cmd_name: str): + cmd_completer = None + cmd_help = None + + if cmd_name not in _REGISTERED_COMMANDS: + raise KeyError('Command ' + cmd_name + ' has not been registered') + + cmd_func = _REGISTERED_COMMANDS[cmd_name] + + module = inspect.getmodule(cmd_func) + + module_funcs = [mf for mf in inspect.getmembers(module) if inspect.isfunction(mf[1])] + for mf in module_funcs: + if mf[0] == COMPLETER_FUNC_PREFIX + cmd_name: + cmd_completer = mf[1] + elif mf[0] == HELP_FUNC_PREFIX + cmd_name: + cmd_help = mf[1] + if cmd_completer is not None and cmd_help is not None: + break + + self.install_command_function(cmd_name, cmd_func, cmd_completer, cmd_help) + def install_command_function(self, cmd_name: str, cmd_func: Callable, @@ -510,8 +532,8 @@ class Cmd(cmd.Cmd): if not valid: raise ValueError("Invalid command name {!r}: {}".format(cmd_name, errmsg)) - assert getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is None,\ - 'Duplicate command function registered: ' + cmd_name + if getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is not None: + raise KeyError('Duplicate command function registered: ' + cmd_name) setattr(self, COMMAND_FUNC_PREFIX + cmd_name, types.MethodType(cmd_func, self)) self._installed_functions.append(cmd_name) if cmd_completer is not None: diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 115cef64..6996bd9d 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -5,17 +5,16 @@ Supports the definition of commands in separate classes to be composed into cmd2 import functools from typing import ( Callable, + Dict, Iterable, - List, Optional, - Tuple, Type, Union, ) -from .constants import COMMAND_FUNC_PREFIX, HELP_FUNC_PREFIX, COMPLETER_FUNC_PREFIX +from .constants import COMMAND_FUNC_PREFIX # Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues -try: +try: # pragma: no cover from typing import TYPE_CHECKING if TYPE_CHECKING: from .cmd2 import Cmd, Statement @@ -23,7 +22,7 @@ try: except ImportError: pass -_UNBOUND_COMMANDS = [] # type: List[Tuple[str, Callable, Optional[Callable], Optional[Callable]]] +_REGISTERED_COMMANDS = {} # type: Dict[str, Callable] """ Registered command tuples. (command, do_ function, complete_ function, help_ function """ @@ -69,24 +68,12 @@ def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Nam """ assert cmd_func.__name__.startswith(COMMAND_FUNC_PREFIX), 'Command functions must start with `do_`' - import inspect - cmd_name = cmd_func.__name__[len(COMMAND_FUNC_PREFIX):] - cmd_completer = None - cmd_help = None - - module = inspect.getmodule(cmd_func) - - module_funcs = [mf for mf in inspect.getmembers(module) if inspect.isfunction(mf[1])] - for mf in module_funcs: - if mf[0] == COMPLETER_FUNC_PREFIX + cmd_name: - cmd_completer = mf[1] - elif mf[0] == HELP_FUNC_PREFIX + cmd_name: - cmd_help = mf[1] - if cmd_completer is not None and cmd_help is not None: - break - _UNBOUND_COMMANDS.append((cmd_name, cmd_func, cmd_completer, cmd_help)) + if cmd_name not in _REGISTERED_COMMANDS: + _REGISTERED_COMMANDS[cmd_name] = cmd_func + else: + raise KeyError('Command ' + cmd_name + ' is already registered') return cmd_func diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index 8b51b7e4..01ce1b39 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -11,8 +11,21 @@ from cmd2.utils import CompletionError @register_command @with_category("AAA") def do_unbound(cmd: Cmd, statement: Statement): + """This is an example of registering an unbound function + + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + +@register_command +@with_category("AAA") +def do_func_with_help(cmd: Cmd, statement: Statement): """ This is an example of registering an unbound function + :param cmd: :param statement: :return: @@ -20,6 +33,10 @@ def do_unbound(cmd: Cmd, statement: Statement): cmd.poutput('Unbound Command: {}'.format(statement.args)) +def help_func_with_help(cmd: Cmd): + cmd.poutput('Help for func_with_help') + + @with_default_category('Basic Completion') class BasicCompletionCommandSet(CommandSet): # List of strings used with completion functions diff --git a/tests/test_commandset.py b/tests/test_commandset.py index 02fff7b2..269c5de9 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -10,6 +10,11 @@ import pytest import cmd2 from cmd2 import utils +from .conftest import ( + complete_tester, + run_cmd, +) + # Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available try: @@ -41,14 +46,14 @@ def do_command_with_support(cmd: cmd2.Cmd, statement: cmd2.Statement): :param statement: :return: """ - cmd.poutput('Unbound Command: {}'.format(statement.args)) + cmd.poutput('Command with support functions: {}'.format(statement.args)) def help_command_with_support(cmd: cmd2.Cmd): cmd.poutput('Help for command_with_support') -def complete_command_with_support(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: +def complete_command_with_support(cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: """Completion function for do_index_based""" food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -96,11 +101,29 @@ class WithCommandSets(cmd2.Cmd): super().__init__(*args, **kwargs) +@cmd2.with_default_category('Command Set B') +class CommandSetB(cmd2.CommandSet): + def __init__(self, arg1): + super().__init__() + self._arg1 = arg1 + + def do_aardvark(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Aardvark!') + + def do_bat(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + """Banana Command""" + cmd.poutput('Bat!!') + + def do_crocodile(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Crocodile!!') + + @pytest.fixture def command_sets_app(): app = WithCommandSets() return app + @pytest.fixture() def command_sets_manual(): app = WithCommandSets(auto_load_commands=False) @@ -120,28 +143,55 @@ def test_autoload_commands(command_sets_app): assert 'cranberry' in cmds_cats['Command Set'] +def test_custom_construct_commandsets(): + command_set = CommandSetB('foo') + app = WithCommandSets(command_sets=[command_set]) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = app._build_command_info() + assert 'Command Set B' in cmds_cats + + command_set_2 = CommandSetB('bar') + with pytest.raises(ValueError): + assert app.install_command_set(command_set_2) + + def test_load_commands(command_sets_manual): cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() - assert 'AAA' not in cmds_cats + # start by verifying none of the installable commands are present + assert 'AAA' not in cmds_cats assert 'Alone' not in cmds_cats - assert 'Command Set' not in cmds_cats - command_sets_manual.install_command_function('unbound', do_unbound, None, None) + # install the `unbound` command + command_sets_manual.install_registered_command('unbound') + with pytest.raises(KeyError): + assert command_sets_manual.install_registered_command('unbound') + + with pytest.raises(KeyError): + assert command_sets_manual.install_registered_command('nonexistent_command') + + def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): + """ + This function duplicates an existing command + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + with pytest.raises(KeyError): + assert cmd2.register_command(do_unbound) + + # verify only the `unbound` command was installed cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'AAA' in cmds_cats assert 'unbound' in cmds_cats['AAA'] - assert 'Alone' not in cmds_cats - assert 'Command Set' not in cmds_cats + # now install a command set and verify the commands are now present cmd_set = CommandSetA() - command_sets_manual.install_command_set(cmd_set) cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() @@ -155,6 +205,7 @@ def test_load_commands(command_sets_manual): assert 'Command Set' in cmds_cats assert 'cranberry' in cmds_cats['Command Set'] + # uninstall the `unbound` command and verify only it was uninstalled command_sets_manual.uninstall_command('unbound') cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() @@ -167,12 +218,87 @@ def test_load_commands(command_sets_manual): assert 'Command Set' in cmds_cats assert 'cranberry' in cmds_cats['Command Set'] + # uninstall the command set and verify it is now also no longer accessible command_sets_manual.uninstall_command_set(cmd_set) cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'AAA' not in cmds_cats - assert 'Alone' not in cmds_cats - assert 'Command Set' not in cmds_cats + + # reinstall the command set and verifyt is accessible but the `unbound` command isn't + command_sets_manual.install_command_set(cmd_set) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'AAA' not in cmds_cats + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Command Set' in cmds_cats + assert 'cranberry' in cmds_cats['Command Set'] + + +def test_command_functions(command_sets_manual): + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'AAA' not in cmds_cats + + out, err = run_cmd(command_sets_manual, 'command_with_support') + assert 'is not a recognized command, alias, or macro' in err[0] + + out, err = run_cmd(command_sets_manual, 'help command_with_support') + assert 'No help on command_with_support' in err[0] + + text = '' + line = 'command_with_support' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + assert first_match is None + + command_sets_manual.install_registered_command('command_with_support') + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'AAA' in cmds_cats + assert 'command_with_support' in cmds_cats['AAA'] + + out, err = run_cmd(command_sets_manual, 'command_with_support') + assert 'Command with support functions' in out[0] + + out, err = run_cmd(command_sets_manual, 'help command_with_support') + assert 'Help for command_with_support' in out[0] + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + assert first_match == 'Ham' + + text = '' + line = 'command_with_support Ham' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match == 'Basket' + + command_sets_manual.uninstall_command('command_with_support') + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'AAA' not in cmds_cats + + out, err = run_cmd(command_sets_manual, 'command_with_support') + assert 'is not a recognized command, alias, or macro' in err[0] + + out, err = run_cmd(command_sets_manual, 'help command_with_support') + assert 'No help on command_with_support' in err[0] + + text = '' + line = 'command_with_support' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + assert first_match is None + |