diff options
Diffstat (limited to 'cmd2')
-rw-r--r-- | cmd2/cmd2.py | 66 | ||||
-rw-r--r-- | cmd2/command_definition.py | 122 |
2 files changed, 186 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 |