summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEric Lin <anselor@gmail.com>2020-06-11 18:00:07 -0400
committeranselor <anselor@gmail.com>2020-08-04 13:38:08 -0400
commite1087b8f29341397b09e9a0722a77c025ab20d23 (patch)
treee63eaa1e117973f1e9870c3b187438ae2e5a735e
parent787c95251e38ef80821f395b6147982876977081 (diff)
downloadcmd2-git-e1087b8f29341397b09e9a0722a77c025ab20d23.tar.gz
Initial implementation of modular command loading
Issue #943 New class CommandSet can be used to tag a class as a command class. If the constructor is simple, the object will automatically be instantiated and loaded. New register_command decorator to tag any arbitrary function as a command.
-rw-r--r--cmd2/cmd2.py66
-rw-r--r--cmd2/command_definition.py122
-rw-r--r--examples/modular_commands/__init__.py0
-rw-r--r--examples/modular_commands/commandset_basic.py105
-rw-r--r--examples/modular_commands/commandset_custominit.py33
-rw-r--r--examples/modular_commands_main.py127
6 files changed, 451 insertions, 2 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 70ec508c..9a98b550 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -37,14 +37,17 @@ import pickle
import re
import sys
import threading
+import types
from code import InteractiveConsole
from collections import namedtuple
from contextlib import redirect_stdout
-from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union
+from typing import Any, AnyStr, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union
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, _PartialPassthru
+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
from .history import History, HistoryItem
@@ -130,7 +133,8 @@ class Cmd(cmd.Cmd):
startup_script: str = '', use_ipython: bool = False,
allow_cli_args: bool = True, transcript_files: Optional[List[str]] = None,
allow_redirection: bool = True, multiline_commands: Optional[List[str]] = None,
- terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None) -> None:
+ terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None,
+ command_sets: Optional[Iterable[CommandSet]] = None) -> None:
"""An easy but powerful framework for writing line-oriented command
interpreters. Extends Python's cmd package.
@@ -381,6 +385,64 @@ class Cmd(cmd.Cmd):
# If False, then complete() will sort the matches using self.default_sort_key before they are displayed.
self.matches_sorted = False
+ # Load modular commands
+ self._command_sets = command_sets if command_sets is not None else []
+ self._load_modular_commands()
+
+ def _load_modular_commands(self) -> None:
+ """
+ Load modular command definitions.
+ :return: None
+ """
+
+ # start by loading registered functions as commands
+ for cmd_name, cmd_func, cmd_completer, cmd_help in _UNBOUND_COMMANDS:
+ assert getattr(self, cmd_func.__name__, None) is None, 'Duplicate command function registered: ' + cmd_name
+ setattr(self, cmd_func.__name__, types.MethodType(cmd_func, self))
+ if cmd_completer is not None:
+ assert getattr(self, cmd_completer.__name__, None) is None, \
+ 'Duplicate command completer registered: ' + cmd_completer.__name__
+ setattr(self, cmd_completer.__name__, types.MethodType(cmd_completer, self))
+ if cmd_help is not None:
+ assert getattr(self, cmd_help.__name__, None) is None, \
+ 'Duplicate command help registered: ' + cmd_help.__name__
+ setattr(self, cmd_help.__name__, types.MethodType(cmd_help, self))
+
+ # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor
+ all_commandset_defs = CommandSet.__subclasses__()
+ existing_commandset_types = [type(command_set) for command_set in self._command_sets]
+ for cmdset_type in all_commandset_defs:
+ init_sig = inspect.signature(cmdset_type.__init__)
+ if cmdset_type in existing_commandset_types or len(init_sig.parameters) != 1 or 'self' not in init_sig.parameters:
+ continue
+ cmdset = cmdset_type()
+ self._command_sets.append(cmdset)
+
+ # initialize each CommandSet and register all matching functions as command, helper, completer functions
+ for cmdset in self._command_sets:
+ cmdset.on_register(self)
+ methods = inspect.getmembers(cmdset, predicate=lambda meth: inspect.ismethod(
+ meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX))
+
+ for method in methods:
+ assert getattr(self, method[0], None) is None, \
+ 'In {}: Duplicate command function: {}'.format(cmdset_type.__name__, method[0])
+
+ command_wrapper = _PartialPassthru(method[1], self)
+ setattr(self, method[0], command_wrapper)
+
+ command = method[0][len(COMMAND_FUNC_PREFIX):]
+
+ completer_func_name = COMPLETER_FUNC_PREFIX + command
+ cmd_completer = getattr(cmdset, completer_func_name, None)
+ if cmd_completer and not getattr(self, completer_func_name, None):
+ completer_wrapper = _PartialPassthru(cmd_completer, self)
+ setattr(self, completer_func_name, completer_wrapper)
+ cmd_help = getattr(cmdset, HELP_FUNC_PREFIX + command, None)
+ if cmd_help and not getattr(self, HELP_FUNC_PREFIX + command, None):
+ help_wrapper = _PartialPassthru(cmd_help, self)
+ setattr(self, HELP_FUNC_PREFIX + command, help_wrapper)
+
def add_settable(self, settable: Settable) -> None:
"""
Convenience method to add a settable parameter to ``self.settables``
diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py
new file mode 100644
index 00000000..f08040bb
--- /dev/null
+++ b/cmd2/command_definition.py
@@ -0,0 +1,122 @@
+# coding=utf-8
+"""
+Supports the definition of commands in separate classes to be composed into cmd2.Cmd
+"""
+import functools
+from typing import (
+ Callable,
+ Iterable,
+ List,
+ Optional,
+ Tuple,
+ Type,
+ Union,
+)
+from .constants import COMMAND_FUNC_PREFIX, HELP_FUNC_PREFIX, COMPLETER_FUNC_PREFIX
+
+# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues
+try:
+ from typing import TYPE_CHECKING
+ if TYPE_CHECKING:
+ from .cmd2 import Cmd, Statement
+ import argparse
+except ImportError:
+ pass
+
+_UNBOUND_COMMANDS = [] # type: List[Tuple[str, Callable, Optional[Callable], Optional[Callable]]]
+"""
+Registered command tuples. (command, do_ function, complete_ function, help_ function
+"""
+
+
+class _PartialPassthru(functools.partial):
+ """
+ Wrapper around partial function that passes through getattr, setattr, and dir to the wrapped function.
+ This allows for CommandSet functions to be wrapped while maintaining the decorated properties
+ """
+ def __getattr__(self, item):
+ return getattr(self.func, item)
+
+ def __setattr__(self, key, value):
+ return setattr(self.func, key, value)
+
+ def __dir__(self) -> Iterable[str]:
+ return dir(self.func)
+
+
+def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Namespace']], None]):
+ """
+ Decorator that allows an arbitrary function to be automatically registered as a command.
+ If there is a help_ or complete_ function that matches this command, that will also be registered.
+
+ :param cmd_func: Function to register as a cmd2 command
+ :return:
+ """
+ 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))
+
+
+def with_default_category(category: str):
+ """
+ Decorator that applies a category to all ``do_*`` command methods in a class that do not already
+ have a category specified.
+
+ :param category: category to put all uncategorized commands in
+ :return: decorator function
+ """
+
+ def decorate_class(cls: Type[CommandSet]):
+ from .constants import CMD_ATTR_HELP_CATEGORY
+ import inspect
+ from .decorators import with_category
+ methods = inspect.getmembers(
+ cls,
+ predicate=lambda meth: inspect.isfunction(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX))
+ category_decorator = with_category(category)
+ for method in methods:
+ if not hasattr(method[1], CMD_ATTR_HELP_CATEGORY):
+ setattr(cls, method[0], category_decorator(method[1]))
+ return cls
+ return decorate_class
+
+
+class CommandSet(object):
+ """
+ Base class for defining sets of commands to load in cmd2.
+
+ ``with_default_category`` can be used to apply a default category to all commands in the CommandSet.
+
+ ``do_``, ``help_``, and ``complete_`` functions differ only in that they're now required to accept
+ a reference to ``cmd2.Cmd`` as the first argument after self.
+ """
+
+ def __init__(self):
+ self._cmd = None # type: Optional[Cmd]
+
+ def on_register(self, cmd: 'Cmd'):
+ """
+ Called by cmd2.Cmd when a CommandSet is registered. Subclasses can override this
+ to perform an initialization requiring access to the Cmd object.
+
+ :param cmd: The cmd2 main application
+ :return: None
+ """
+ self._cmd = cmd
diff --git a/examples/modular_commands/__init__.py b/examples/modular_commands/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/examples/modular_commands/__init__.py
diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py
new file mode 100644
index 00000000..5ad26d97
--- /dev/null
+++ b/examples/modular_commands/commandset_basic.py
@@ -0,0 +1,105 @@
+# coding=utf-8
+"""
+A simple example demonstrating a loadable command set
+"""
+from typing import List
+
+from cmd2 import Cmd, Statement, with_category
+from cmd2.command_definition import CommandSet, with_default_category, register_command
+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))
+
+
+@with_default_category('Basic Completion')
+class BasicCompletionCommandSet(CommandSet):
+ # List of strings used with completion functions
+ food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
+ sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
+
+ # This data is used to demonstrate delimiter_complete
+ file_strs = \
+ [
+ '/home/user/file.db',
+ '/home/user/file space.db',
+ '/home/user/another.db',
+ '/home/other user/maps.db',
+ '/home/other user/tests.db'
+ ]
+
+ def do_flag_based(self, cmd: Cmd, statement: Statement):
+ """Tab completes arguments based on a preceding flag using flag_based_complete
+ -f, --food [completes food items]
+ -s, --sport [completes sports]
+ -p, --path [completes local file system paths]
+ """
+ cmd.poutput("Args: {}".format(statement.args))
+
+ def complete_flag_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """Completion function for do_flag_based"""
+ flag_dict = \
+ {
+ # Tab complete food items after -f and --food flags in command line
+ '-f': self.food_item_strs,
+ '--food': self.food_item_strs,
+
+ # Tab complete sport items after -s and --sport flags in command line
+ '-s': self.sport_item_strs,
+ '--sport': self.sport_item_strs,
+
+ # Tab complete using path_complete function after -p and --path flags in command line
+ '-p': cmd.path_complete,
+ '--path': cmd.path_complete,
+ }
+
+ return cmd.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict)
+
+ def do_index_based(self, cmd: Cmd, statement: Statement):
+ """Tab completes first 3 arguments using index_based_complete"""
+ cmd.poutput("Args: {}".format(statement.args))
+
+ def complete_index_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """Completion function for do_index_based"""
+ index_dict = \
+ {
+ 1: self.food_item_strs, # Tab complete food items at index 1 in command line
+ 2: self.sport_item_strs, # Tab complete sport items at index 2 in command line
+ 3: cmd.path_complete, # Tab complete using path_complete function at index 3 in command line
+ }
+
+ return cmd.index_based_complete(text, line, begidx, endidx, index_dict=index_dict)
+
+ def do_delimiter_complete(self, cmd: Cmd, statement: Statement):
+ """Tab completes files from a list using delimiter_complete"""
+ cmd.poutput("Args: {}".format(statement.args))
+
+ def complete_delimiter_complete(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ return cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/')
+
+ def do_raise_error(self, cmd: Cmd, statement: Statement):
+ """Demonstrates effect of raising CompletionError"""
+ cmd.poutput("Args: {}".format(statement.args))
+
+ def complete_raise_error(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """
+ CompletionErrors can be raised if an error occurs while tab completing.
+
+ Example use cases
+ - Reading a database to retrieve a tab completion data set failed
+ - A previous command line argument that determines the data set being completed is invalid
+ """
+ raise CompletionError("This is how a CompletionError behaves")
+
+ @with_category('Not Basic Completion')
+ def do_custom_category(self, cmd: Cmd, statement: Statement):
+ cmd.poutput('Demonstrates a command that bypasses the default category')
diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py
new file mode 100644
index 00000000..440db850
--- /dev/null
+++ b/examples/modular_commands/commandset_custominit.py
@@ -0,0 +1,33 @@
+# coding=utf-8
+"""
+A simple example demonstrating a loadable command set
+"""
+from cmd2 import Cmd, Statement, with_category
+from cmd2.command_definition import CommandSet, with_default_category, register_command
+
+
+@register_command
+@with_category("AAA")
+def do_another_command(cmd: Cmd, statement: Statement):
+ """
+ This is an example of registering an unbound function
+ :param cmd:
+ :param statement:
+ :return:
+ """
+ cmd.poutput('Another Unbound Command: {}'.format(statement.args))
+
+
+@with_default_category('Custom Init')
+class CustomInitCommandSet(CommandSet):
+ def __init__(self, arg1, arg2):
+ super().__init__()
+
+ self._arg1 = arg1
+ self._arg2 = arg2
+
+ def do_show_arg1(self, cmd: Cmd, _: Statement):
+ cmd.poutput('Arg1: ' + self._arg1)
+
+ def do_show_arg2(self, cmd: Cmd, _: Statement):
+ cmd.poutput('Arg2: ' + self._arg2)
diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py
new file mode 100644
index 00000000..93dc79ea
--- /dev/null
+++ b/examples/modular_commands_main.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""
+A simple example demonstrating how to integrate tab completion with argparse-based commands.
+"""
+import argparse
+from typing import Dict, List
+
+from cmd2 import Cmd, Cmd2ArgumentParser, CompletionItem, with_argparser
+from cmd2.utils import CompletionError, basic_complete
+from modular_commands.commandset_basic import BasicCompletionCommandSet # noqa: F401
+from modular_commands.commandset_custominit import CustomInitCommandSet
+
+# Data source for argparse.choices
+food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
+
+
+def choices_function() -> List[str]:
+ """Choices functions are useful when the choice list is dynamically generated (e.g. from data in a database)"""
+ return ['a', 'dynamic', 'list', 'goes', 'here']
+
+
+def completer_function(text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """
+ A tab completion function not dependent on instance data. Since custom tab completion operations commonly
+ need to modify cmd2's instance variables related to tab completion, it will be rare to need a completer
+ function. completer_method should be used in those cases.
+ """
+ match_against = ['a', 'dynamic', 'list', 'goes', 'here']
+ return basic_complete(text, line, begidx, endidx, match_against)
+
+
+def choices_completion_item() -> List[CompletionItem]:
+ """Return CompletionItem instead of strings. These give more context to what's being tab completed."""
+ items = \
+ {
+ 1: "My item",
+ 2: "Another item",
+ 3: "Yet another item"
+ }
+ return [CompletionItem(item_id, description) for item_id, description in items.items()]
+
+
+def choices_arg_tokens(arg_tokens: Dict[str, List[str]]) -> List[str]:
+ """
+ If a choices or completer function/method takes a value called arg_tokens, then it will be
+ passed a dictionary that maps the command line tokens up through the one being completed
+ to their argparse argument name. All values of the arg_tokens dictionary are lists, even if
+ a particular argument expects only 1 token.
+ """
+ # Check if choices_function flag has appeared
+ values = ['choices_function', 'flag']
+ if 'choices_function' in arg_tokens:
+ values.append('is {}'.format(arg_tokens['choices_function'][0]))
+ else:
+ values.append('not supplied')
+ return values
+
+
+class WithCommandSets(Cmd):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
+
+ def choices_method(self) -> List[str]:
+ """Choices methods are useful when the choice list is based on instance data of your application"""
+ return self.sport_item_strs
+
+ def choices_completion_error(self) -> List[str]:
+ """
+ CompletionErrors can be raised if an error occurs while tab completing.
+
+ Example use cases
+ - Reading a database to retrieve a tab completion data set failed
+ - A previous command line argument that determines the data set being completed is invalid
+ """
+ if self.debug:
+ return self.sport_item_strs
+ raise CompletionError("debug must be true")
+
+ # Parser for example command
+ example_parser = Cmd2ArgumentParser(description="Command demonstrating tab completion with argparse\n"
+ "Notice even the flags of this command tab complete")
+
+ # Tab complete from a list using argparse choices. Set metavar if you don't
+ # want the entire choices list showing in the usage text for this command.
+ example_parser.add_argument('--choices', choices=food_item_strs, metavar="CHOICE",
+ help="tab complete using choices")
+
+ # Tab complete from choices provided by a choices function and choices method
+ example_parser.add_argument('--choices_function', choices_function=choices_function,
+ help="tab complete using a choices_function")
+ example_parser.add_argument('--choices_method', choices_method=choices_method,
+ help="tab complete using a choices_method")
+
+ # Tab complete using a completer function and completer method
+ example_parser.add_argument('--completer_function', completer_function=completer_function,
+ help="tab complete using a completer_function")
+ example_parser.add_argument('--completer_method', completer_method=Cmd.path_complete,
+ help="tab complete using a completer_method")
+
+ # Demonstrate raising a CompletionError while tab completing
+ example_parser.add_argument('--completion_error', choices_method=choices_completion_error,
+ help="raise a CompletionError while tab completing if debug is False")
+
+ # Demonstrate returning CompletionItems instead of strings
+ example_parser.add_argument('--completion_item', choices_function=choices_completion_item, metavar="ITEM_ID",
+ descriptive_header="Description",
+ help="demonstrate use of CompletionItems")
+
+ # Demonstrate use of arg_tokens dictionary
+ example_parser.add_argument('--arg_tokens', choices_function=choices_arg_tokens,
+ help="demonstrate use of arg_tokens dictionary")
+
+ @with_argparser(example_parser)
+ def do_example(self, _: argparse.Namespace) -> None:
+ """The example command"""
+ self.poutput("I do nothing")
+
+
+if __name__ == '__main__':
+ import sys
+
+ print("Starting")
+ command_sets = [CustomInitCommandSet('First argument', 'Second argument')]
+ app = WithCommandSets(command_sets=command_sets)
+ sys.exit(app.cmdloop())