diff options
-rw-r--r-- | CHANGELOG.md | 5 | ||||
-rw-r--r-- | cmd2/cmd2.py | 46 | ||||
-rw-r--r-- | cmd2/command_definition.py | 6 | ||||
-rw-r--r-- | tests_isolated/test_commandset/test_commandset.py | 39 |
4 files changed, 81 insertions, 15 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index de57031d..2ba8c53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ -## 1.3.2 (August 7, 2020) +## 1.3.2 (August 10, 2020) * Bug Fixes * Fixed `prog` value of subcommands added with `as_subcommand_to()` decorator. * Fixed missing settings in subcommand parsers created with `as_subcommand_to()` decorator. These settings include things like description and epilog text. + * Fixed issue with CommandSet auto-discovery only searching direct sub-classes +* Enhancements + * Added functions to fetch registered CommandSets by type and command name ## 1.3.1 (August 6, 2020) * Bug Fixes diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 975f4635..39feac68 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -406,18 +406,47 @@ class Cmd(cmd.Cmd): self._register_subcommands(self) + def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]: + """ + Find all CommandSets that match the provided CommandSet type. + By default, locates a CommandSet that is an exact type match but may optionally return all CommandSets that + are sub-classes of the provided type + :param commandset_type: CommandSet sub-class type to search for + :param subclass_match: If True, return all sub-classes of provided type, otherwise only search for exact match + :return: Matching CommandSets + """ + return [cmdset for cmdset in self._installed_command_sets + if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type))] + + def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]: + """ + Finds the CommandSet that registered the command name + :param command_name: command name to search + :return: CommandSet that provided the command + """ + return self._cmd_to_command_sets.get(command_name) + def _autoload_commands(self) -> None: """Load modular command definitions.""" - # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor + # Search for all subclasses of CommandSet, instantiate them if they weren't already provided in the constructor all_commandset_defs = CommandSet.__subclasses__() existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] - for cmdset_type in all_commandset_defs: - init_sig = inspect.signature(cmdset_type.__init__) - if not (cmdset_type in existing_commandset_types - or len(init_sig.parameters) != 1 - or 'self' not in init_sig.parameters): - cmdset = cmdset_type() - self.install_command_set(cmdset) + + def load_commandset_by_type(commandset_types: List[Type]) -> None: + for cmdset_type in commandset_types: + # check if the type has sub-classes. We will only auto-load leaf class types. + subclasses = cmdset_type.__subclasses__() + if subclasses: + load_commandset_by_type(subclasses) + else: + init_sig = inspect.signature(cmdset_type.__init__) + if not (cmdset_type in existing_commandset_types + or len(init_sig.parameters) != 1 + or 'self' not in init_sig.parameters): + cmdset = cmdset_type() + self.install_command_set(cmdset) + + load_commandset_by_type(all_commandset_defs) def install_command_set(self, cmdset: CommandSet) -> None: """ @@ -471,6 +500,7 @@ class Cmd(cmd.Cmd): if cmdset in self._cmd_to_command_sets.values(): self._cmd_to_command_sets = \ {key: val for key, val in self._cmd_to_command_sets.items() if val is not cmdset} + cmdset.on_unregister(self) raise def _install_command_function(self, command: str, command_wrapper: Callable, context=''): diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 1858c80b..22d8915e 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -6,6 +6,7 @@ import functools from typing import Callable, Iterable, Optional, Type from .constants import COMMAND_FUNC_PREFIX +from .exceptions import CommandSetRegistrationError # Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues try: # pragma: no cover @@ -92,7 +93,10 @@ class CommandSet(object): :param cmd: The cmd2 main application :type cmd: cmd2.Cmd """ - self._cmd = cmd + if self._cmd is None: + self._cmd = cmd + else: + raise CommandSetRegistrationError('This CommandSet has already been registered') def on_unregister(self, cmd): """ diff --git a/tests_isolated/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py index 43947071..813f9183 100644 --- a/tests_isolated/test_commandset/test_commandset.py +++ b/tests_isolated/test_commandset/test_commandset.py @@ -15,8 +15,12 @@ from .conftest import complete_tester, WithCommandSets from cmd2.exceptions import CommandSetRegistrationError +class CommandSetBase(cmd2.CommandSet): + pass + + @cmd2.with_default_category('Fruits') -class CommandSetA(cmd2.CommandSet): +class CommandSetA(CommandSetBase): def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement): cmd.poutput('Apple!') @@ -60,7 +64,7 @@ class CommandSetA(cmd2.CommandSet): @cmd2.with_default_category('Command Set B') -class CommandSetB(cmd2.CommandSet): +class CommandSetB(CommandSetBase): def __init__(self, arg1): super().__init__() self._arg1 = arg1 @@ -95,8 +99,8 @@ def test_autoload_commands(command_sets_app): def test_custom_construct_commandsets(): # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor - command_set = CommandSetB('foo') - app = WithCommandSets(command_sets=[command_set]) + command_set_b = CommandSetB('foo') + app = WithCommandSets(command_sets=[command_set_b]) cmds_cats, cmds_doc, cmds_undoc, help_topics = app._build_command_info() assert 'Command Set B' in cmds_cats @@ -107,16 +111,41 @@ def test_custom_construct_commandsets(): assert app.install_command_set(command_set_2) # Verify that autoload doesn't conflict with a manually loaded CommandSet that could be autoloaded. - app2 = WithCommandSets(command_sets=[CommandSetA()]) + command_set_a = CommandSetA() + app2 = WithCommandSets(command_sets=[command_set_a]) + + with pytest.raises(CommandSetRegistrationError): + app2.install_command_set(command_set_b) + + app.uninstall_command_set(command_set_b) + + app2.install_command_set(command_set_b) + assert hasattr(app2, 'do_apple') + assert hasattr(app2, 'do_aardvark') + + assert app2.find_commandset_for_command('aardvark') is command_set_b + assert app2.find_commandset_for_command('apple') is command_set_a + + matches = app2.find_commandsets(CommandSetBase, subclass_match=True) + assert command_set_a in matches + assert command_set_b in matches + assert command_set_2 not in matches def test_load_commands(command_sets_manual): # now install a command set and verify the commands are now present cmd_set = CommandSetA() + + assert command_sets_manual.find_commandset_for_command('elderberry') is None + assert not command_sets_manual.find_commandsets(CommandSetA) + command_sets_manual.install_command_set(cmd_set) + assert command_sets_manual.find_commandsets(CommandSetA)[0] is cmd_set + assert command_sets_manual.find_commandset_for_command('elderberry') is cmd_set + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'Alone' in cmds_cats |