Skip to content

Commit

Permalink
Will now traverse down CommandSet inheritance tree to find all leaf d…
Browse files Browse the repository at this point in the history
…escendants.

CommandSet now has a check to ensure it is only registered with one
cmd2.Cmd instance at a time.
Adds function to find command set by type and by command name
  • Loading branch information
anselor committed Aug 10, 2020
1 parent 2674341 commit 6c939fb
Show file tree
Hide file tree
Showing 4 changed files with 81 additions and 15 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 38 additions & 8 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,18 +406,47 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,

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:
"""
Expand Down Expand Up @@ -471,6 +500,7 @@ def install_command_set(self, cmdset: CommandSet) -> None:
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=''):
Expand Down
6 changes: 5 additions & 1 deletion cmd2/command_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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
Expand Down Expand Up @@ -92,7 +93,10 @@ def on_register(self, cmd):
: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):
"""
Expand Down
39 changes: 34 additions & 5 deletions tests_isolated/test_commandset/test_commandset.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
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!')

Expand Down Expand Up @@ -60,7 +64,7 @@ def do_elderberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace):


@cmd2.with_default_category('Command Set B')
class CommandSetB(cmd2.CommandSet):
class CommandSetB(CommandSetBase):
def __init__(self, arg1):
super().__init__()
self._arg1 = arg1
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 6c939fb

Please sign in to comment.