diff options
-rw-r--r-- | cmd2/cmd2.py | 66 | ||||
-rw-r--r-- | cmd2/command_definition.py | 122 | ||||
-rw-r--r-- | examples/modular_commands/__init__.py | 0 | ||||
-rw-r--r-- | examples/modular_commands/commandset_basic.py | 105 | ||||
-rw-r--r-- | examples/modular_commands/commandset_custominit.py | 33 | ||||
-rw-r--r-- | examples/modular_commands_main.py | 127 |
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()) |