diff options
-rw-r--r-- | cmd2/cmd2.py | 199 | ||||
-rw-r--r-- | cmd2/parsing.py | 108 | ||||
-rw-r--r-- | cmd2/plugin.py | 23 | ||||
-rw-r--r-- | docs/hooks.rst | 317 | ||||
-rw-r--r-- | tests/test_parsing.py | 95 | ||||
-rw-r--r-- | tests/test_plugin.py | 799 |
6 files changed, 1459 insertions, 82 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 42e00c39..8db7cef3 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -34,6 +34,7 @@ import cmd import collections from colorama import Fore import glob +import inspect import os import platform import re @@ -43,6 +44,7 @@ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union from . import constants from . import utils +from . import plugin from .argparse_completer import AutoCompleter, ACArgumentParser from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .parsing import StatementParser, Statement @@ -381,6 +383,10 @@ class Cmd(cmd.Cmd): import atexit atexit.register(readline.write_history_file, persistent_history_file) + # initialize plugin system + # needs to be done before we call __init__(0) + self._initialize_plugin_system() + # Call super class constructor super().__init__(completekey=completekey, stdin=stdin, stdout=stdout) @@ -1680,12 +1686,35 @@ class Cmd(cmd.Cmd): :return: True if cmdloop() should exit, False otherwise """ import datetime + stop = False try: statement = self._complete_statement(line) - (stop, statement) = self.postparsing_precmd(statement) + except EmptyStatement: + return self._run_cmdfinalization_hooks(stop, None) + except ValueError as ex: + # If shlex.split failed on syntax, let user know whats going on + self.perror("Invalid syntax: {}".format(ex), traceback_war=False) + return stop + + # now that we have a statement, run it with all the hooks + try: + # call the postparsing hooks + data = plugin.PostparsingData(False, statement) + for func in self._postparsing_hooks: + data = func(data) + if data.stop: + break + # postparsing_precmd is deprecated + if not data.stop: + (data.stop, data.statement) = self.postparsing_precmd(data.statement) + # unpack the data object + statement = data.statement + stop = data.stop if stop: - return self.postparsing_postcmd(stop) + # we should not run the command, but + # we need to run the finalization hooks + raise EmptyStatement try: if self.allow_redirection: @@ -1693,23 +1722,53 @@ class Cmd(cmd.Cmd): timestart = datetime.datetime.now() if self._in_py: self._last_result = None + + # precommand hooks + data = plugin.PrecommandData(statement) + for func in self._precmd_hooks: + data = func(data) + statement = data.statement + # call precmd() for compatibility with cmd.Cmd statement = self.precmd(statement) + + # go run the command function stop = self.onecmd(statement) + + # postcommand hooks + data = plugin.PostcommandData(stop, statement) + for func in self._postcmd_hooks: + data = func(data) + # retrieve the final value of stop, ignoring any statement modification from the hooks + stop = data.stop + # call postcmd() for compatibility with cmd.Cmd stop = self.postcmd(stop, statement) + if self.timing: self.pfeedback('Elapsed: %s' % str(datetime.datetime.now() - timestart)) finally: if self.allow_redirection and self.redirecting: self._restore_output(statement) except EmptyStatement: + # don't do anything, but do allow command finalization hooks to run pass - except ValueError as ex: - # If shlex.split failed on syntax, let user know whats going on - self.perror("Invalid syntax: {}".format(ex), traceback_war=False) except Exception as ex: self.perror(ex) finally: + return self._run_cmdfinalization_hooks(stop, statement) + + def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement) -> bool: + """Run the command finalization hooks""" + try: + data = plugin.CommandFinalizationData(stop, statement) + for func in self._cmdfinalization_hooks: + data = func(data) + # retrieve the final value of stop, ignoring any + # modifications to the statement + stop = data.stop + # postparsing_postcmd is deprecated return self.postparsing_postcmd(stop) + except Exception as ex: + self.perror(ex) def runcmds_plus_hooks(self, cmds: List[str]) -> bool: """Convenience method to run multiple commands by onecmd_plus_hooks. @@ -3063,6 +3122,8 @@ Script should contain one command per line, just like command would be typed in self.cmdqueue.extend(callargs) # Always run the preloop first + for func in self._preloop_hooks: + func() self.preloop() # If transcript-based regression testing was requested, then do that instead of the main loop @@ -3081,8 +3142,136 @@ Script should contain one command per line, just like command would be typed in self._cmdloop() # Run the postloop() no matter what + for func in self._postloop_hooks: + func() self.postloop() + ### + # + # plugin related functions + # + ### + def _initialize_plugin_system(self): + """Initialize the plugin system""" + self._preloop_hooks = [] + self._postloop_hooks = [] + self._postparsing_hooks = [] + self._precmd_hooks = [] + self._postcmd_hooks = [] + self._cmdfinalization_hooks = [] + + @classmethod + def _validate_callable_param_count(cls, func, count): + """Ensure a function has the given number of parameters.""" + signature = inspect.signature(func) + # validate that the callable has the right number of parameters + nparam = len(signature.parameters) + if nparam != count: + raise TypeError('{} has {} positional arguments, expected {}'.format( + func.__name__, + nparam, + count, + )) + + @classmethod + def _validate_prepostloop_callable(cls, func): + """Check parameter and return types for preloop and postloop hooks.""" + cls._validate_callable_param_count(func, 0) + # make sure there is no return notation + signature = inspect.signature(func) + if signature.return_annotation != None: + raise TypeError("{} must declare return a return type of 'None'".format( + func.__name__, + )) + + def register_preloop_hook(self, func): + """Register a function to be called at the beginning of the command loop.""" + self._validate_prepostloop_callable(func) + self._preloop_hooks.append(func) + + def register_postloop_hook(self, func): + """Register a function to be called at the end of the command loop.""" + self._validate_prepostloop_callable(func) + self._postloop_hooks.append(func) + + @classmethod + def _validate_postparsing_callable(cls, func): + """Check parameter and return types for postparsing hooks""" + cls._validate_callable_param_count(func, 1) + signature = inspect.signature(func) + _, param = list(signature.parameters.items())[0] + if param.annotation != plugin.PostparsingData: + raise TypeError("{} must have one parameter declared with type 'cmd2.plugin.PostparsingData'".format( + func.__name__ + )) + if signature.return_annotation != plugin.PostparsingData: + raise TypeError("{} must declare return a return type of 'cmd2.plugin.PostparsingData'".format( + func.__name__ + )) + + def register_postparsing_hook(self, func): + """Register a function to be called after parsing user input but before running the command""" + self._validate_postparsing_callable(func) + self._postparsing_hooks.append(func) + + @classmethod + def _validate_prepostcmd_hook(cls, func, data_type): + """Check parameter and return types for pre and post command hooks.""" + signature = inspect.signature(func) + # validate that the callable has the right number of parameters + cls._validate_callable_param_count(func, 1) + # validate the parameter has the right annotation + paramname = list(signature.parameters.keys())[0] + param = signature.parameters[paramname] + if param.annotation != data_type: + raise TypeError('argument 1 of {} has incompatible type {}, expected {}'.format( + func.__name__, + param.annotation, + data_type, + )) + # validate the return value has the right annotation + if signature.return_annotation == signature.empty: + raise TypeError('{} does not have a declared return type, expected {}'.format( + func.__name__, + data_type, + )) + if signature.return_annotation != data_type: + raise TypeError('{} has incompatible return type {}, expected {}'.format( + func.__name__, + signature.return_annotation, + data_type, + )) + + def register_precmd_hook(self, func): + """Register a hook to be called before the command function.""" + self._validate_prepostcmd_hook(func, plugin.PrecommandData) + self._precmd_hooks.append(func) + + def register_postcmd_hook(self, func): + """Register a hook to be called after the command function.""" + self._validate_prepostcmd_hook(func, plugin.PostcommandData) + self._postcmd_hooks.append(func) + + @classmethod + def _validate_cmdfinalization_callable(cls, func): + """Check parameter and return types for command finalization hooks.""" + cls._validate_callable_param_count(func, 1) + signature = inspect.signature(func) + _, param = list(signature.parameters.items())[0] + if param.annotation != plugin.CommandFinalizationData: + raise TypeError("{} must have one parameter declared with type 'cmd2.plugin.CommandFinalizationData'".format( + func.__name__ + )) + if signature.return_annotation != plugin.CommandFinalizationData: + raise TypeError("{} must declare return a return type of 'cmd2.plugin.CommandFinalizationData'".format( + func.__name__ + )) + pass + + def register_cmdfinalization_hook(self, func): + """Register a hook to be called after a command is completed, whether it completes successfully or not.""" + self._validate_cmdfinalization_callable(func) + self._cmdfinalization_hooks.append(func) class History(list): """ A list of HistoryItems that knows how to respond to user requests. """ diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 8d59aedb..b220f1c4 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -5,7 +5,7 @@ import os import re import shlex -from typing import List, Tuple +from typing import List, Tuple, Dict from . import constants from . import utils @@ -21,6 +21,10 @@ class Statement(str): need a place to capture the additional output of the command parsing, so we add our own attributes to this subclass. + Instances of this class should not be created by anything other than the + `StatementParser.parse()` method, nor should any of the attributes be modified + once the object is created. + The string portion of the class contains the arguments, but not the command, nor the output redirection clauses. @@ -54,18 +58,39 @@ class Statement(str): :type output_to: str or None """ - def __init__(self, obj): - super().__init__() - self.raw = str(obj) - self.command = None - self.multiline_command = None - self.args = None - self.argv = None - self.terminator = None - self.suffix = None - self.pipe_to = None - self.output = None - self.output_to = None + def __new__(cls, + obj: object, + *, + raw: str = None, + command: str = None, + args: str = None, + argv: List[str] = None, + multiline_command: str = None, + terminator: str = None, + suffix: str = None, + pipe_to: str = None, + output: str = None, + output_to:str = None + ): + """Create a new instance of Statement + + We must override __new__ because we are subclassing `str` which is + immutable. + """ + stmt = str.__new__(cls, obj) + object.__setattr__(stmt, "raw", raw) + object.__setattr__(stmt, "command", command) + object.__setattr__(stmt, "args", args) + if argv is None: + argv = [] + object.__setattr__(stmt, "argv", argv) + object.__setattr__(stmt, "multiline_command", multiline_command) + object.__setattr__(stmt, "terminator", terminator) + object.__setattr__(stmt, "suffix", suffix) + object.__setattr__(stmt, "pipe_to", pipe_to) + object.__setattr__(stmt, "output", output) + object.__setattr__(stmt, "output_to", output_to) + return stmt @property def command_and_args(self): @@ -82,6 +107,13 @@ class Statement(str): rtn = None return rtn + def __setattr__(self, name, value): + """Statement instances should feel immutable; raise ValueError""" + raise ValueError + + def __delattr__(self, name): + """Statement instances should feel immutable; raise ValueError""" + raise ValueError class StatementParser: """Parse raw text into command components. @@ -90,11 +122,11 @@ class StatementParser: """ def __init__( self, - allow_redirection=True, - terminators=None, - multiline_commands=None, - aliases=None, - shortcuts=None, + allow_redirection: bool = True, + terminators: List[str] = None, + multiline_commands: List[str] = None, + aliases: Dict[str, str] = None, + shortcuts: Dict[str, str] = None, ): self.allow_redirection = allow_redirection if terminators is None: @@ -228,7 +260,7 @@ class StatementParser: tokens = self._split_on_punctuation(list(lexer)) return tokens - def parse(self, rawinput: str) -> Statement: + def parse(self, line: str) -> Statement: """Tokenize the input and parse it into a Statement object, stripping comments, expanding aliases and shortcuts, and extracting output redirection directives. @@ -240,7 +272,7 @@ class StatementParser: # we have to do this before we tokenize because tokenizing # destroys all unquoted whitespace in the input terminator = None - if rawinput[-1:] == LINE_FEED: + if line[-1:] == LINE_FEED: terminator = LINE_FEED command = None @@ -248,7 +280,7 @@ class StatementParser: argv = None # lex the input into a list of tokens - tokens = self.tokenize(rawinput) + tokens = self.tokenize(line) # of the valid terminators, find the first one to occur in the input terminator_pos = len(tokens) + 1 @@ -360,19 +392,18 @@ class StatementParser: # build the statement # string representation of args must be an empty string instead of # None for compatibility with standard library cmd - statement = Statement('' if args is None else args) - statement.raw = rawinput - statement.command = command - # if there are no args we will use None since we don't have to worry - # about compatibility with standard library cmd - statement.args = args - statement.argv = list(map(lambda x: utils.strip_quotes(x), argv)) - statement.terminator = terminator - statement.output = output - statement.output_to = output_to - statement.pipe_to = pipe_to - statement.suffix = suffix - statement.multiline_command = multiline_command + statement = Statement('' if args is None else args, + raw=line, + command=command, + args=args, + argv=list(map(lambda x: utils.strip_quotes(x), argv)), + multiline_command=multiline_command, + terminator=terminator, + suffix=suffix, + pipe_to=pipe_to, + output=output, + output_to=output_to, + ) return statement def parse_command_only(self, rawinput: str) -> Statement: @@ -422,10 +453,11 @@ class StatementParser: # build the statement # string representation of args must be an empty string instead of # None for compatibility with standard library cmd - statement = Statement('' if args is None else args) - statement.raw = rawinput - statement.command = command - statement.args = args + statement = Statement('' if args is None else args, + raw=rawinput, + command=command, + args=args, + ) return statement def _expand(self, line: str) -> str: diff --git a/cmd2/plugin.py b/cmd2/plugin.py new file mode 100644 index 00000000..5c68dfb9 --- /dev/null +++ b/cmd2/plugin.py @@ -0,0 +1,23 @@ +# +# coding=utf-8 +"""Classes for the cmd2 plugin system""" +import attr + +@attr.s +class PostparsingData(): + stop = attr.ib() + statement = attr.ib() + +@attr.s +class PrecommandData(): + statement = attr.ib() + +@attr.s +class PostcommandData(): + stop = attr.ib() + statement = attr.ib() + +@attr.s +class CommandFinalizationData(): + stop = attr.ib() + statement = attr.ib() diff --git a/docs/hooks.rst b/docs/hooks.rst index 1e96e963..0ec5d7e9 100644 --- a/docs/hooks.rst +++ b/docs/hooks.rst @@ -1,43 +1,314 @@ -.. cmd2 documentation for application and command lifecycle and the hooks which are available +.. cmd2 documentation for application and command lifecycle and the available hooks cmd2 Application Lifecycle and Hooks ==================================== The typical way of starting a cmd2 application is as follows:: - from cmd2 import Cmd - class App(Cmd): + import cmd2 + class App(cmd2.Cmd): # customized attributes and methods here - app = App() - app.cmdloop() -There are several pre-existing methods and attributes which you can tweak to control the overall behavior of your -application before, during, and after the main loop. + if __name__ == '__main__': + app = App() + app.cmdloop() -Application Lifecycle Hook Methods ----------------------------------- -The ``preloop`` and ``postloop`` methods run before and after the main loop, respectively. +There are several pre-existing methods and attributes which you can tweak to +control the overall behavior of your application before, during, and after the +command processing loop. -.. automethod:: cmd2.cmd2.Cmd.preloop +Application Lifecycle Hooks +--------------------------- + +You can register methods to be called at the beginning of the command loop:: + + class App(cmd2.Cmd): + def __init__(self, *args, *kwargs): + super().__init__(*args, **kwargs) + self.register_preloop_hook(self.myhookmethod) + + def myhookmethod(self): + self.poutput("before the loop begins") + +And also after the command loop has finished:: + + class App(cmd2.Cmd): + def __init__(self, *args, *kwargs): + super().__init__(*args, **kwargs) + self.register_postloop_hook(self.myhookmethod) + + def myhookmethod(self): + self.poutput("before the loop begins") + +Preloop and postloop hook methods are not passed any parameters and any return +value is ignored. -.. automethod:: cmd2.cmd2.Cmd.postloop Application Lifecycle Attributes -------------------------------- -There are numerous attributes (member variables of the ``cmd2.Cmd``) which have a significant effect on the application -behavior upon entering or during the main loop. A partial list of some of the more important ones is presented here: +There are numerous attributes (member variables of the ``cmd2.Cmd``) which have +a significant effect on the application behavior upon entering or during the +main loop. A partial list of some of the more important ones is presented here: + +- **intro**: *str* - if provided this serves as the intro banner printed once + at start of application, after ``preloop`` runs +- **allow_cli_args**: *bool* - if True (default), then searches for -t or + --test at command line to invoke transcript testing mode instead of a normal + main loop and also processes any commands provided as arguments on the + command line just prior to entering the main loop +- **echo**: *bool* - if True, then the command line entered is echoed to the + screen (most useful when running scripts) +- **prompt**: *str* - sets the prompt which is displayed, can be dynamically + changed based on application state and/or command results + + +Command Processing Loop +----------------------- + +When you call `.cmdloop()`, the following sequence of events are repeated until +the application exits: + +1. Output the prompt +2. Accept user input +3. Parse user input into `Statement` object +4. Call methods registered with `register_postparsing_hook()` +5. Call `postparsing_precmd()` - for backwards compatibility with prior releases of cmd2, now deprecated +6. Redirect output, if user asked for it and it's allowed +7. Start timer +8. Call methods registered with `register_precmd_hook()` +9. Call `precmd()` - for backwards compatibility with ``cmd.Cmd`` +10. Add statement to history +11. Call `do_command` method +12. Call methods registered with `register_postcmd_hook()` +13. Call `postcmd(stop, statement)` - for backwards compatibility with ``cmd.Cmd`` +14. Stop timer and display the elapsed time +15. Stop redirecting output if it was redirected +16. Call methods registered with `register_cmdfinalization_hook()` +17. Call `postparsing_postcmd()` - for backwards compatibility - deprecated + +By registering hook methods, steps 4, 8, 12, and 16 allow you to run code +during, and control the flow of the command processing loop. Be aware that +plugins also utilize these hooks, so there may be code running that is not part +of your application. Methods registered for a hook are called in the order they +were registered. You can register a function more than once, and it will be +called each time it was registered. + +Postparsing, precomamnd, and postcommand hook methods share some common ways to +influence the command processing loop. + +If a hook raises a ``cmd2.EmptyStatement`` exception: +- no more hooks (except command finalization hooks) of any kind will be called +- if the command has not yet been executed, it will not be executed +- no error message will be displayed to the user + +If a hook raises any other exception: +- no more hooks (except command finalization hooks) of any kind will be called +- if the command has not yet been executed, it will not be executed +- the exception message will be displayed for the user. + +Specific types of hook methods have additional options as described below. + +Postparsing Hooks +^^^^^^^^^^^^^^^^^ + +Postparsing hooks are called after the user input has been parsed but before +execution of the comamnd. These hooks can be used to: + +- modify the user input +- run code before every command executes +- cancel execution of the current command +- exit the application + +When postparsing hooks are called, output has not been redirected, nor has the +timer for command execution been started. + +To define and register a postparsing hook, do the following:: + + class App(cmd2.Cmd): + def __init__(self, *args, *kwargs): + super().__init__(*args, **kwargs) + self.register_postparsing_hook(self.myhookmethod) + + def myhookmethod(self, params: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: + # the statement object created from the user input + # is available as params.statement + return params + +``register_postparsing_hook()`` checks the method signature of the passed callable, +and raises a ``TypeError`` if it has the wrong number of parameters. It will +also raise a ``TypeError`` if the passed parameter and return value are not annotated +as ``PostparsingData``. + + +The hook method will be passed one parameter, a ``PostparsingData`` object +which we will refer to as ``params``. ``params`` contains two attributes. +``params.statement`` is a ``Statement`` object which describes the parsed +user input. There are many useful attributes in the ``Statement`` +object, including ``.raw`` which contains exactly what the user typed. +``params.stop`` is set to ``False`` by default. + +The hook method must return a ``PostparsingData`` object, and it is very +convenient to just return the object passed into the hook method. The hook +method may modify the attributes of the object to influece the behavior of +the application. If ``params.stop`` is set to true, a fatal failure is +triggered prior to execution of the command, and the application exits. + +To modify the user input, you create a new ``Statement`` object and return it in +``params.statement``. Don't try and directly modify the contents of a +``Statement`` object, there be dragons. Instead, use the various attributes in a +``Statement`` object to construct a new string, and then parse that string to +create a new ``Statement`` object. + +``cmd2.Cmd()`` uses an instance of ``cmd2.StatementParser`` to parse user input. +This instance has been configured with the proper command terminators, multiline +commands, and other parsing related settings. This instance is available as the +``self.statement_parser`` attribute. Here's a simple example which shows the +proper technique:: + + def myhookmethod(self, params: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: + if not '|' in params.statement.raw: + newinput = params.statement.raw + ' | less' + params.statement = self.statement_parser.parse(newinput) + return params + +If a postparsing hook returns a ``PostparsingData`` object with the ``stop`` +attribute set to ``True``: + +- no more hooks of any kind (except command finalization hooks) will be called +- the command will not be executed +- no error message will be displayed to the user +- the application will exit -- **intro**: *str* - if provided this serves as the intro banner printed once at start of application, after ``preloop`` runs -- **allow_cli_args**: *bool* - if True (default), then searches for -t or --test at command line to invoke transcript testing mode instead of a normal main loop - and also processes any commands provided as arguments on the command line just prior to entering the main loop -- **echo**: *bool* - if True, then the command line entered is echoed to the screen (most useful when running scripts) -- **prompt**: *str* - sets the prompt which is displayed, can be dynamically changed based on application state and/or - command results +Precommand Hooks +^^^^^^^^^^^^^^^^^ + +Precommand hooks can modify the user input, but can not request the application +terminate. If your hook needs to be able to exit the application, you should +implement it as a postparsing hook. + +Once output is redirected and the timer started, all the hooks registered with +``register_precmd_hook()`` are called. Here's how to do it:: + + class App(cmd2.Cmd): + def __init__(self, *args, *kwargs): + super().__init__(*args, **kwargs) + self.register_precmd_hook(self.myhookmethod) + + def myhookmethod(self, data: cmd2.plugin.PrecommandData) -> cmd2.plugin.PrecommandData: + # the statement object created from the user input + # is available as data.statement + return data + +``register_precmd_hook()`` checks the method signature of the passed callable, +and raises a ``TypeError`` if it has the wrong number of parameters. It will +also raise a ``TypeError`` if the parameters and return value are not annotated +as ``PrecommandData``. + +You may choose to modify the user input by creating a new ``Statement`` with +different properties (see above). If you do so, assign your new ``Statement`` +object to ``data.statement``. + +The precommand hook must return a ``PrecommandData`` object. You don't have to +create this object from scratch, you can just return the one passed into the hook. + +After all registered precommand hooks have been called, +``self.precmd(statement)`` will be called. To retain full backward compatibility +with ``cmd.Cmd``, this method is passed a ``Statement``, not a +``PrecommandData`` object. + + +Postcommand Hooks +^^^^^^^^^^^^^^^^^^ + +Once the command method has returned (i.e. the ``do_command(self, statement) +method`` has been called and returns, all postcommand hooks are called. If +output was redirected by the user, it is still redirected, and the command timer +is still running. + +Here's how to define and register a postcommand hook:: + + class App(cmd2.Cmd): + def __init__(self, *args, *kwargs): + super().__init__(*args, **kwargs) + self.register_postcmd_hook(self.myhookmethod) + + def myhookmethod(self, data: cmd2.plugin.PostcommandData) -> cmd2.plugin.PostcommandData: + return stop + +Your hook will be passed a ``PostcommandData`` object, which has a ``statement`` +attribute that describes the command which was executed. If your postcommand +hook method gets called, you are guaranteed that the command method was called, +and that it didn't raise an exception. + +If any postcommand hook raises an exception, the exception will be displayed to the user, and no further postcommand hook methods +will be called. Command finalization hooks, if any, will be called. + +After all registered precommand hooks have been called, +``self.postcmd(statement)`` will be called to retain full backward compatibility +with ``cmd.Cmd``. + +If any postcommand hook (registered or ``self.postcmd()``) returns ``True``, +subsequent postcommand hooks will still be called, as will the command +finalization hooks, but once those hooks have all been called, the application +will terminate. + +Any postcommand hook can change the value of the ``stop`` parameter before +returning it, and the modified value will be passed to the next postcommand +hook. The value returned by the final postcommand hook will be passed to the +command finalization hooks, which may further modify the value. If your hook +blindly returns ``False``, a prior hook's requst to exit the application will +not be honored. It's best to return the value you were passed unless you have a +compelling reason to do otherwise. + + +Command Finalization Hooks +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Command finalization hooks are called even if one of the other types of hooks or +the command method raise an exception. Here's how to create and register a +command finalization hook:: + + class App(cmd2.Cmd): + def __init__(self, *args, *kwargs): + super().__init__(*args, **kwargs) + self.register_cmdfinalization_hook(self.myhookmethod) + + def myhookmethod(self, stop, statement): + return stop + +Command Finalization hooks must check whether the statement object is ``None``. There are certain circumstances where these hooks may be called before the statement has been parsed, so you can't always rely on having a statement. + +If any prior postparsing or precommand hook has requested the application to +terminate, the value of the ``stop`` parameter passed to the first command +finalization hook will be ``True``. Any command finalization hook can change the +value of the ``stop`` parameter before returning it, and the modified value will +be passed to the next command finalization hook. The value returned by the final +command finalization hook will determine whether the application terminates or +not. + +This approach to command finalization hooks can be powerful, but it can also +cause problems. If your hook blindly returns ``False``, a prior hook's requst to +exit the application will not be honored. It's best to return the value you were +passed unless you have a compelling reason to do otherwise. + +If any command finalization hook raises an exception, no more command +finalization hooks will be called. If the last hook to return a value returned +``True``, then the exception will be rendered, and the application will +terminate. + +Deprecated Application Lifecycle Hook Methods +--------------------------------------------- + +The ``preloop`` and ``postloop`` methods run before and after the main loop, respectively. + +.. automethod:: cmd2.cmd2.Cmd.preloop + +.. automethod:: cmd2.cmd2.Cmd.postloop -Command Processing Hooks ------------------------- +Deprecated Command Processing Hooks +----------------------------------- Inside the main loop, every time the user hits <Enter> the line is processed by the ``onecmd_plus_hooks`` method. @@ -56,4 +327,4 @@ the various hook methods, presented in chronological order starting with the one .. automethod:: cmd2.cmd2.Cmd.postcmd -.. automethod:: cmd2.cmd2.Cmd.postparsing_postcmd +.. automethod:: cmd2.cmd2.Cmd.postparsing_postcmd
\ No newline at end of file diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 59f9a610..0865b6f6 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -30,6 +30,7 @@ def test_parse_empty_string(parser): statement = parser.parse('') assert not statement.command assert not statement.args + assert statement == '' assert statement.raw == '' @pytest.mark.parametrize('line,tokens', [ @@ -71,7 +72,8 @@ def test_command_and_args(parser, tokens, command, args): def test_parse_single_word(parser, line): statement = parser.parse(line) assert statement.command == line - assert not statement.args + assert statement.args is None + assert statement == '' assert statement.argv == [utils.strip_quotes(line)] @pytest.mark.parametrize('line,terminator', [ @@ -83,8 +85,10 @@ def test_parse_single_word(parser, line): def test_parse_word_plus_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.terminator == terminator + assert statement.args is None + assert statement == '' assert statement.argv == ['termbare'] + assert statement.terminator == terminator @pytest.mark.parametrize('line,terminator', [ ('termbare; suffx', ';'), @@ -95,15 +99,18 @@ def test_parse_word_plus_terminator(parser, line, terminator): def test_parse_suffix_after_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' + assert statement.args is None + assert statement == '' + assert statement.argv == ['termbare'] assert statement.terminator == terminator assert statement.suffix == 'suffx' - assert statement.argv == ['termbare'] def test_parse_command_with_args(parser): line = 'command with args' statement = parser.parse(line) assert statement.command == 'command' assert statement.args == 'with args' + assert statement == statement.args assert statement.argv == ['command', 'with', 'args'] def test_parse_command_with_quoted_args(parser): @@ -111,6 +118,7 @@ def test_parse_command_with_quoted_args(parser): statement = parser.parse(line) assert statement.command == 'command' assert statement.args == 'with "quoted args" and "some not"' + assert statement == statement.args assert statement.argv == ['command', 'with', 'quoted args', 'and', 'some not'] def test_parse_command_with_args_terminator_and_suffix(parser): @@ -118,22 +126,25 @@ def test_parse_command_with_args_terminator_and_suffix(parser): statement = parser.parse(line) assert statement.command == 'command' assert statement.args == "with args and terminator" + assert statement.argv == ['command', 'with', 'args', 'and', 'terminator'] + assert statement == statement.args assert statement.terminator == ';' assert statement.suffix == 'and suffix' - assert statement.argv == ['command', 'with', 'args', 'and', 'terminator'] def test_parse_hashcomment(parser): statement = parser.parse('hi # this is all a comment') assert statement.command == 'hi' - assert not statement.args + assert statement.args is None + assert statement == '' assert statement.argv == ['hi'] def test_parse_c_comment(parser): statement = parser.parse('hi /* this is | all a comment */') assert statement.command == 'hi' - assert not statement.args - assert not statement.pipe_to + assert statement.args is None assert statement.argv == ['hi'] + assert statement == '' + assert not statement.pipe_to def test_parse_c_comment_empty(parser): statement = parser.parse('/* this is | all a comment */') @@ -141,13 +152,15 @@ def test_parse_c_comment_empty(parser): assert not statement.args assert not statement.pipe_to assert not statement.argv + assert statement == '' def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): statement = parser.parse('what if "quoted strings /* seem to " start comments?') assert statement.command == 'what' assert statement.args == 'if "quoted strings /* seem to " start comments?' - assert not statement.pipe_to assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?'] + assert statement == statement.args + assert not statement.pipe_to @pytest.mark.parametrize('line',[ 'simple | piped', @@ -156,7 +169,8 @@ def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): def test_parse_simple_pipe(parser, line): statement = parser.parse(line) assert statement.command == 'simple' - assert not statement.args + assert statement.args is None + assert statement == '' assert statement.argv == ['simple'] assert statement.pipe_to == ['piped'] @@ -165,6 +179,7 @@ def test_parse_double_pipe_is_not_a_pipe(parser): statement = parser.parse(line) assert statement.command == 'double-pipe' assert statement.args == '|| is not a pipe' + assert statement == statement.args assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe'] assert not statement.pipe_to @@ -174,6 +189,7 @@ def test_parse_complex_pipe(parser): assert statement.command == 'command' assert statement.args == "with args, terminator" assert statement.argv == ['command', 'with', 'args,', 'terminator'] + assert statement == statement.args assert statement.terminator == '&' assert statement.suffix == 'sufx' assert statement.pipe_to == ['piped'] @@ -187,7 +203,8 @@ def test_parse_complex_pipe(parser): def test_parse_redirect(parser,line, output): statement = parser.parse(line) assert statement.command == 'help' - assert not statement.args + assert statement.args is None + assert statement == '' assert statement.output == output assert statement.output_to == 'out.txt' @@ -196,6 +213,7 @@ def test_parse_redirect_with_args(parser): statement = parser.parse(line) assert statement.command == 'output' assert statement.args == 'into' + assert statement == statement.args assert statement.argv == ['output', 'into'] assert statement.output == '>' assert statement.output_to == 'afile.txt' @@ -205,6 +223,7 @@ def test_parse_redirect_with_dash_in_path(parser): statement = parser.parse(line) assert statement.command == 'output' assert statement.args == 'into' + assert statement == statement.args assert statement.argv == ['output', 'into'] assert statement.output == '>' assert statement.output_to == 'python-cmd2/afile.txt' @@ -214,6 +233,7 @@ def test_parse_redirect_append(parser): statement = parser.parse(line) assert statement.command == 'output' assert statement.args == 'appended to' + assert statement == statement.args assert statement.argv == ['output', 'appended', 'to'] assert statement.output == '>>' assert statement.output_to == '/tmp/afile.txt' @@ -223,6 +243,7 @@ def test_parse_pipe_and_redirect(parser): statement = parser.parse(line) assert statement.command == 'output' assert statement.args == 'into' + assert statement == statement.args assert statement.argv == ['output', 'into'] assert statement.terminator == ';' assert statement.suffix == 'sufx' @@ -235,6 +256,7 @@ def test_parse_output_to_paste_buffer(parser): statement = parser.parse(line) assert statement.command == 'output' assert statement.args == 'to paste buffer' + assert statement == statement.args assert statement.argv == ['output', 'to', 'paste', 'buffer'] assert statement.output == '>>' @@ -246,6 +268,7 @@ def test_parse_redirect_inside_terminator(parser): statement = parser.parse(line) assert statement.command == 'has' assert statement.args == '> inside' + assert statement == statement.args assert statement.argv == ['has', '>', 'inside'] assert statement.terminator == ';' @@ -263,6 +286,7 @@ def test_parse_multiple_terminators(parser, line, terminator): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.args == 'with | inside' + assert statement == statement.args assert statement.argv == ['multiline', 'with', '|', 'inside'] assert statement.terminator == terminator @@ -272,6 +296,7 @@ def test_parse_unfinished_multiliine_command(parser): assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' assert statement.args == 'has > inside an unfinished command' + assert statement == statement.args assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command'] assert not statement.terminator @@ -286,6 +311,7 @@ def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, ter statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.args == 'has > inside' + assert statement == statement.args assert statement.argv == ['multiline', 'has', '>', 'inside'] assert statement.terminator == terminator @@ -297,6 +323,7 @@ def test_parse_multiline_with_incomplete_comment(parser): assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' assert statement.args == 'command' + assert statement == statement.args assert statement.argv == ['multiline', 'command'] assert not statement.terminator @@ -306,6 +333,7 @@ def test_parse_multiline_with_complete_comment(parser): assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' assert statement.args == 'command is done' + assert statement == statement.args assert statement.argv == ['multiline', 'command', 'is', 'done'] assert statement.terminator == ';' @@ -315,6 +343,7 @@ def test_parse_multiline_termninated_by_empty_line(parser): assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' assert statement.args == 'command ends' + assert statement == statement.args assert statement.argv == ['multiline', 'command', 'ends'] assert statement.terminator == '\n' @@ -324,6 +353,7 @@ def test_parse_multiline_ignores_terminators_in_comments(parser): assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' assert statement.args == 'command "with term; ends" now' + assert statement == statement.args assert statement.argv == ['multiline', 'command', 'with term; ends', 'now'] assert statement.terminator == '\n' @@ -332,6 +362,7 @@ def test_parse_command_with_unicode_args(parser): statement = parser.parse(line) assert statement.command == 'drink' assert statement.args == 'café' + assert statement == statement.args assert statement.argv == ['drink', 'café'] def test_parse_unicode_command(parser): @@ -339,6 +370,7 @@ def test_parse_unicode_command(parser): statement = parser.parse(line) assert statement.command == 'café' assert statement.args == 'au lait' + assert statement == statement.args assert statement.argv == ['café', 'au', 'lait'] def test_parse_redirect_to_unicode_filename(parser): @@ -346,6 +378,7 @@ def test_parse_redirect_to_unicode_filename(parser): statement = parser.parse(line) assert statement.command == 'dir' assert statement.args == 'home' + assert statement == statement.args assert statement.argv == ['dir', 'home'] assert statement.output == '>' assert statement.output_to == 'café' @@ -375,6 +408,10 @@ def test_parse_alias_and_shortcut_expansion(parser, line, command, args): statement = parser.parse(line) assert statement.command == command assert statement.args == args + if statement.args is None: + assert statement == '' + else: + assert statement == statement.args def test_parse_alias_on_multiline_command(parser): line = 'anothermultiline has > inside an unfinished command' @@ -382,6 +419,7 @@ def test_parse_alias_on_multiline_command(parser): assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' assert statement.args == 'has > inside an unfinished command' + assert statement == statement.args assert not statement.terminator @pytest.mark.parametrize('line,output', [ @@ -393,7 +431,8 @@ def test_parse_alias_on_multiline_command(parser): def test_parse_alias_redirection(parser, line, output): statement = parser.parse(line) assert statement.command == 'help' - assert not statement.args + assert statement.args is None + assert statement == '' assert statement.output == output assert statement.output_to == 'out.txt' @@ -404,7 +443,8 @@ def test_parse_alias_redirection(parser, line, output): def test_parse_alias_pipe(parser, line): statement = parser.parse(line) assert statement.command == 'help' - assert not statement.args + assert statement.args is None + assert statement == '' assert statement.pipe_to == ['less'] @pytest.mark.parametrize('line', [ @@ -418,7 +458,8 @@ def test_parse_alias_pipe(parser, line): def test_parse_alias_terminator_no_whitespace(parser, line): statement = parser.parse(line) assert statement.command == 'help' - assert not statement.args + assert statement.args is None + assert statement == '' assert statement.terminator == ';' def test_parse_command_only_command_and_args(parser): @@ -426,6 +467,7 @@ def test_parse_command_only_command_and_args(parser): statement = parser.parse_command_only(line) assert statement.command == 'help' assert statement.args == 'history' + assert statement == statement.args assert statement.command_and_args == line def test_parse_command_only_emptyline(parser): @@ -434,9 +476,9 @@ def test_parse_command_only_emptyline(parser): # statement is a subclass of str(), the value of the str # should be '', to retain backwards compatibility with # the cmd in the standard library - assert statement == '' assert statement.command is None assert statement.args is None + assert statement == '' assert not statement.argv assert statement.command_and_args == None @@ -445,6 +487,7 @@ def test_parse_command_only_strips_line(parser): statement = parser.parse_command_only(line) assert statement.command == 'help' assert statement.args == 'history' + assert statement == statement.args assert statement.command_and_args == line.strip() def test_parse_command_only_expands_alias(parser): @@ -452,12 +495,14 @@ def test_parse_command_only_expands_alias(parser): statement = parser.parse_command_only(line) assert statement.command == 'pyscript' assert statement.args == 'foobar.py' + assert statement == statement.args def test_parse_command_only_expands_shortcuts(parser): line = '!cat foobar.txt' statement = parser.parse_command_only(line) assert statement.command == 'shell' assert statement.args == 'cat foobar.txt' + assert statement == statement.args assert statement.command_and_args == 'shell cat foobar.txt' def test_parse_command_only_quoted_args(parser): @@ -465,6 +510,7 @@ def test_parse_command_only_quoted_args(parser): statement = parser.parse_command_only(line) assert statement.command == 'shell' assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' + assert statement == statement.args assert statement.command_and_args == line.replace('l', 'shell ls -al') @pytest.mark.parametrize('line', [ @@ -495,5 +541,22 @@ def test_parse_command_only_specialchars(parser, line): ]) def test_parse_command_only_none(parser, line): statement = parser.parse_command_only(line) - assert statement.command == None - assert statement.args == None + assert statement.command is None + assert statement.args is None + assert statement == '' + +def test_statement_initialization(parser): + string = 'alias' + statement = cmd2.Statement(string) + assert string == statement + assert statement.raw is None + assert statement.command is None + assert statement.args is None + assert isinstance(statement.argv, list) + assert not statement.argv + assert statement.multiline_command is None + assert statement.terminator is None + assert statement.suffix is None + assert statement.pipe_to is None + assert statement.output is None + assert statement.output_to is None diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 00000000..fd8609c3 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,799 @@ +# coding=utf-8 +""" +Test plugin infrastructure and hooks. + +Copyright 2018 Jared Crapo <jared@kotfu.net> +Released under MIT license, see LICENSE file +""" + +from typing import Tuple + +import pytest + +import cmd2 +from cmd2 import plugin + +from .conftest import StdOut + +class Plugin: + "A mixin class for testing hook registration and calling" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.reset_counters() + + def reset_counters(self): + self.called_postparsing = 0 + self.called_precmd = 0 + self.called_postcmd = 0 + self.called_cmdfinalization = 0 + + ### + # + # preloop and postloop hooks + # which share the same signature and are thus interchangable + # + ### + def prepost_hook_one(self) -> None: + "Method used for preloop or postloop hooks" + self.poutput("one") + + def prepost_hook_two(self) -> None: + "Another method used for preloop or postloop hooks" + self.poutput("two") + + def prepost_hook_too_many_parameters(self, param) -> None: + "A preloop or postloop hook with too many parameters" + pass + + def prepost_hook_with_wrong_return_annotation(self) -> bool: + "A preloop or postloop hook with incorrect return type" + pass + + ### + # + # Postparsing hooks + # + ### + def postparse_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: + "A postparsing hook" + self.called_postparsing += 1 + return data + + def postparse_hook_stop(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: + "A postparsing hook with requests application exit" + self.called_postparsing += 1 + data.stop = True + return data + + def postparse_hook_emptystatement(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: + "A postparsing hook with raises an EmptyStatement exception" + self.called_postparsing += 1 + raise cmd2.EmptyStatement + + def postparse_hook_exception(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: + "A postparsing hook which raises an exception" + self.called_postparsing += 1 + raise ValueError + + def postparse_hook_too_many_parameters(self, data1, data2) -> cmd2.plugin.PostparsingData: + "A postparsing hook with too many parameters" + pass + + def postparse_hook_undeclared_parameter_annotation(self, data) -> cmd2.plugin.PostparsingData: + "A postparsing hook with an undeclared parameter type" + pass + + def postparse_hook_wrong_parameter_annotation(self, data: str) -> cmd2.plugin.PostparsingData: + "A postparsing hook with the wrong parameter type" + pass + + def postparse_hook_undeclared_return_annotation(self, data: cmd2.plugin.PostparsingData): + "A postparsing hook with an undeclared return type" + pass + + def postparse_hook_wrong_return_annotation(self, data: cmd2.plugin.PostparsingData) -> str: + "A postparsing hook with the wrong return type" + pass + + ### + # + # precommand hooks, some valid, some invalid + # + ### + def precmd(self, statement: cmd2.Statement) -> cmd2.Statement: + "Override cmd.Cmd method" + self.called_precmd += 1 + return statement + + def precmd_hook(self, data: plugin.PrecommandData) -> plugin.PrecommandData: + "A precommand hook" + self.called_precmd += 1 + return data + + def precmd_hook_emptystatement(self, data: plugin.PrecommandData) -> plugin.PrecommandData: + "A precommand hook which raises an EmptyStatement exception" + self.called_precmd += 1 + raise cmd2.EmptyStatement + + def precmd_hook_exception(self, data: plugin.PrecommandData) -> plugin.PrecommandData: + "A precommand hook which raises an exception" + self.called_precmd += 1 + raise ValueError + + def precmd_hook_not_enough_parameters(self) -> plugin.PrecommandData: + "A precommand hook with no parameters" + pass + + def precmd_hook_too_many_parameters(self, one: plugin.PrecommandData, two: str) -> plugin.PrecommandData: + "A precommand hook with too many parameters" + return one + + def precmd_hook_no_parameter_annotation(self, data) -> plugin.PrecommandData: + "A precommand hook with no type annotation on the parameter" + return data + + def precmd_hook_wrong_parameter_annotation(self, data: str) -> plugin.PrecommandData: + "A precommand hook with the incorrect type annotation on the parameter" + return data + + def precmd_hook_no_return_annotation(self, data: plugin.PrecommandData): + "A precommand hook with no type annotation on the return value" + return data + + def precmd_hook_wrong_return_annotation(self, data: plugin.PrecommandData) -> cmd2.Statement: + return self.statement_parser.parse('hi there') + + ### + # + # postcommand hooks, some valid, some invalid + # + ### + def postcmd(self, stop: bool, statement: cmd2.Statement) -> bool: + "Override cmd.Cmd method" + self.called_postcmd += 1 + return stop + + def postcmd_hook(self, data: plugin.PostcommandData) -> plugin.PostcommandData: + "A postcommand hook" + self.called_postcmd += 1 + return data + + def postcmd_hook_exception(self, data: plugin.PostcommandData) -> plugin.PostcommandData: + "A postcommand hook with raises an exception" + self.called_postcmd += 1 + raise ZeroDivisionError + + def postcmd_hook_not_enough_parameters(self) -> plugin.PostcommandData: + "A precommand hook with no parameters" + pass + + def postcmd_hook_too_many_parameters(self, one: plugin.PostcommandData, two: str) -> plugin.PostcommandData: + "A precommand hook with too many parameters" + return one + + def postcmd_hook_no_parameter_annotation(self, data) -> plugin.PostcommandData: + "A precommand hook with no type annotation on the parameter" + return data + + def postcmd_hook_wrong_parameter_annotation(self, data: str) -> plugin.PostcommandData: + "A precommand hook with the incorrect type annotation on the parameter" + return data + + def postcmd_hook_no_return_annotation(self, data: plugin.PostcommandData): + "A precommand hook with no type annotation on the return value" + return data + + def postcmd_hook_wrong_return_annotation(self, data: plugin.PostcommandData) -> cmd2.Statement: + return self.statement_parser.parse('hi there') + + ### + # + # command finalization hooks, some valid, some invalid + # + ### + def cmdfinalization_hook(self, data: plugin.CommandFinalizationData) -> plugin.CommandFinalizationData: + """A command finalization hook.""" + self.called_cmdfinalization += 1 + return data + + def cmdfinalization_hook_stop(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plugin.CommandFinalizationData: + "A postparsing hook which requests application exit" + self.called_cmdfinalization += 1 + data.stop = True + return data + + def cmdfinalization_hook_exception(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plugin.CommandFinalizationData: + "A postparsing hook which raises an exception" + self.called_cmdfinalization += 1 + raise ValueError + + def cmdfinalization_hook_not_enough_parameters(self) -> plugin.CommandFinalizationData: + """A command finalization hook with no parameters.""" + pass + + def cmdfinalization_hook_too_many_parameters(self, one: plugin.CommandFinalizationData, two: str) -> plugin.CommandFinalizationData: + """A command finalization hook with too many parameters.""" + return one + + def cmdfinalization_hook_no_parameter_annotation(self, data) -> plugin.CommandFinalizationData: + """A command finalization hook with no type annotation on the parameter.""" + return data + + def cmdfinalization_hook_wrong_parameter_annotation(self, data: str) -> plugin.CommandFinalizationData: + """A command finalization hook with the incorrect type annotation on the parameter.""" + return data + + def cmdfinalization_hook_no_return_annotation(self, data: plugin.CommandFinalizationData): + """A command finalizationhook with no type annotation on the return value.""" + return data + + def cmdfinalization_hook_wrong_return_annotation(self, data: plugin.CommandFinalizationData) -> cmd2.Statement: + """A command finalization hook with the wrong return type annotation.""" + return self.statement_parser.parse('hi there') + + +class PluggedApp(Plugin, cmd2.Cmd): + "A sample app with a plugin mixed in" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def do_say(self, statement): + """Repeat back the arguments""" + self.poutput(statement) + +### +# +# test pre and postloop hooks +# +### +def test_register_preloop_hook_too_many_parameters(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_preloop_hook(app.prepost_hook_too_many_parameters) + +def test_register_preloop_hook_with_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_preloop_hook(app.prepost_hook_with_wrong_return_annotation) + +def test_preloop_hook(capsys): + app = PluggedApp() + app.register_preloop_hook(app.prepost_hook_one) + app.cmdqueue.append('say hello') + app.cmdqueue.append('quit') + app.cmdloop() + out, err = capsys.readouterr() + assert out == 'one\nhello\n' + assert not err + +def test_preloop_hooks(capsys): + app = PluggedApp() + app.register_preloop_hook(app.prepost_hook_one) + app.register_preloop_hook(app.prepost_hook_two) + app.cmdqueue.append('say hello') + app.cmdqueue.append('quit') + app.cmdloop() + out, err = capsys.readouterr() + assert out == 'one\ntwo\nhello\n' + assert not err + +def test_register_postloop_hook_too_many_parameters(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postloop_hook(app.prepost_hook_too_many_parameters) + +def test_register_postloop_hook_with_wrong_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postloop_hook(app.prepost_hook_with_wrong_return_annotation) + +def test_postloop_hook(capsys): + app = PluggedApp() + app.register_postloop_hook(app.prepost_hook_one) + app.cmdqueue.append('say hello') + app.cmdqueue.append('quit') + app.cmdloop() + out, err = capsys.readouterr() + assert out == 'hello\none\n' + assert not err + +def test_postloop_hooks(capsys): + app = PluggedApp() + app.register_postloop_hook(app.prepost_hook_one) + app.register_postloop_hook(app.prepost_hook_two) + app.cmdqueue.append('say hello') + app.cmdqueue.append('quit') + app.cmdloop() + out, err = capsys.readouterr() + assert out == 'hello\none\ntwo\n' + assert not err + +### +# +# test postparsing hooks +# +### +def test_postparsing_hook_too_many_parameters(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postparsing_hook(app.postparse_hook_too_many_parameters) + +def test_postparsing_hook_undeclared_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postparsing_hook(app.postparse_hook_undeclared_parameter_annotation) + +def test_postparsing_hook_wrong_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postparsing_hook(app.postparse_hook_wrong_parameter_annotation) + +def test_postparsing_hook_undeclared_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postparsing_hook(app.postparse_hook_undeclared_return_annotation) + +def test_postparsing_hook_wrong_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postparsing_hook(app.postparse_hook_wrong_return_annotation) + +def test_postparsing_hook(capsys): + app = PluggedApp() + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert not app.called_postparsing + + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_postparsing == 1 + + # register the function again, so it should be called twice + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_postparsing == 2 + +def test_postparsing_hook_stop_first(capsys): + app = PluggedApp() + app.register_postparsing_hook(app.postparse_hook_stop) + stop = app.onecmd_plus_hooks('say hello') + assert app.called_postparsing == 1 + assert stop + + # register another function but it shouldn't be called + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook) + stop = app.onecmd_plus_hooks('say hello') + assert app.called_postparsing == 1 + assert stop + +def test_postparsing_hook_stop_second(capsys): + app = PluggedApp() + app.register_postparsing_hook(app.postparse_hook) + stop = app.onecmd_plus_hooks('say hello') + assert app.called_postparsing == 1 + assert not stop + + # register another function and make sure it gets called + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook_stop) + stop = app.onecmd_plus_hooks('say hello') + assert app.called_postparsing == 2 + assert stop + + # register a third function which shouldn't be called + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook) + stop = app.onecmd_plus_hooks('say hello') + assert app.called_postparsing == 2 + assert stop + +def test_postparsing_hook_emptystatement_first(capsys): + app = PluggedApp() + app.register_postparsing_hook(app.postparse_hook_emptystatement) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + assert app.called_postparsing == 1 + + # register another function but it shouldn't be called + app.reset_counters() + stop = app.register_postparsing_hook(app.postparse_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + assert app.called_postparsing == 1 + +def test_postparsing_hook_emptystatement_second(capsys): + app = PluggedApp() + app.register_postparsing_hook(app.postparse_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert not err + assert app.called_postparsing == 1 + + # register another function and make sure it gets called + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook_emptystatement) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + assert app.called_postparsing == 2 + + # register a third function which shouldn't be called + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + assert app.called_postparsing == 2 + +def test_postparsing_hook_exception(capsys): + app = PluggedApp() + app.register_postparsing_hook(app.postparse_hook_exception) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert err + assert app.called_postparsing == 1 + + # register another function, but it shouldn't be called + app.reset_counters() + app.register_postparsing_hook(app.postparse_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert err + assert app.called_postparsing == 1 + +### +# +# test precmd hooks +# +##### +def test_register_precmd_hook_parameter_count(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_precmd_hook(app.precmd_hook_not_enough_parameters) + with pytest.raises(TypeError): + app.register_precmd_hook(app.precmd_hook_too_many_parameters) + +def test_register_precmd_hook_no_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_precmd_hook(app.precmd_hook_no_parameter_annotation) + +def test_register_precmd_hook_wrong_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_precmd_hook(app.precmd_hook_wrong_parameter_annotation) + +def test_register_precmd_hook_no_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_precmd_hook(app.precmd_hook_no_return_annotation) + +def test_register_precmd_hook_wrong_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_precmd_hook(app.precmd_hook_wrong_return_annotation) + +def test_precmd_hook(capsys): + app = PluggedApp() + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + # without registering any hooks, precmd() should be called + assert app.called_precmd == 1 + + app.reset_counters() + app.register_precmd_hook(app.precmd_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + # with one hook registered, we should get precmd() and the hook + assert app.called_precmd == 2 + + # register the function again, so it should be called twice + app.reset_counters() + app.register_precmd_hook(app.precmd_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + # with two hooks registered, we should get precmd() and both hooks + assert app.called_precmd == 3 + +def test_precmd_hook_emptystatement_first(capsys): + app = PluggedApp() + app.register_precmd_hook(app.precmd_hook_emptystatement) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + # since the registered hooks are called before precmd(), if a registered + # hook throws an exception, precmd() is never called + assert app.called_precmd == 1 + + # register another function but it shouldn't be called + app.reset_counters() + stop = app.register_precmd_hook(app.precmd_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + # the exception raised by the first hook should prevent the second + # hook from being called, and it also prevents precmd() from being + # called + assert app.called_precmd == 1 + +def test_precmd_hook_emptystatement_second(capsys): + app = PluggedApp() + app.register_precmd_hook(app.precmd_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert not err + # with one hook registered, we should get precmd() and the hook + assert app.called_precmd == 2 + + # register another function and make sure it gets called + app.reset_counters() + app.register_precmd_hook(app.precmd_hook_emptystatement) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + # since the registered hooks are called before precmd(), if a registered + # hook throws an exception, precmd() is never called + assert app.called_precmd == 2 + + # register a third function which shouldn't be called + app.reset_counters() + app.register_precmd_hook(app.precmd_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert not out + assert not err + # the exception raised by the second hook should prevent the third + # hook from being called. since the registered hooks are called before precmd(), + # if a registered hook throws an exception, precmd() is never called + assert app.called_precmd == 2 + +### +# +# test postcmd hooks +# +#### +def test_register_postcmd_hook_parameter_count(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postcmd_hook(app.postcmd_hook_not_enough_parameters) + with pytest.raises(TypeError): + app.register_postcmd_hook(app.postcmd_hook_too_many_parameters) + +def test_register_postcmd_hook_no_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postcmd_hook(app.postcmd_hook_no_parameter_annotation) + +def test_register_postcmd_hook_wrong_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postcmd_hook(app.postcmd_hook_wrong_parameter_annotation) + +def test_register_postcmd_hook_no_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postcmd_hook(app.postcmd_hook_no_return_annotation) + +def test_register_postcmd_hook_wrong_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_postcmd_hook(app.postcmd_hook_wrong_return_annotation) + +def test_postcmd(capsys): + app = PluggedApp() + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + # without registering any hooks, postcmd() should be called + assert app.called_postcmd == 1 + + app.reset_counters() + app.register_postcmd_hook(app.postcmd_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + # with one hook registered, we should get precmd() and the hook + assert app.called_postcmd == 2 + + # register the function again, so it should be called twice + app.reset_counters() + app.register_postcmd_hook(app.postcmd_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + # with two hooks registered, we should get precmd() and both hooks + assert app.called_postcmd == 3 + +def test_postcmd_exception_first(capsys): + app = PluggedApp() + app.register_postcmd_hook(app.postcmd_hook_exception) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert err + # since the registered hooks are called before postcmd(), if a registered + # hook throws an exception, postcmd() is never called. So we should have + # a count of one because we called the hook that raised the exception + assert app.called_postcmd == 1 + + # register another function but it shouldn't be called + app.reset_counters() + stop = app.register_postcmd_hook(app.postcmd_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert err + # the exception raised by the first hook should prevent the second + # hook from being called, and it also prevents postcmd() from being + # called + assert app.called_postcmd == 1 + +def test_postcmd_exception_second(capsys): + app = PluggedApp() + app.register_postcmd_hook(app.postcmd_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert not err + # with one hook registered, we should get the hook and postcmd() + assert app.called_postcmd == 2 + + # register another function which should be called + app.reset_counters() + stop = app.register_postcmd_hook(app.postcmd_hook_exception) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert err + # the exception raised by the first hook should prevent the second + # hook from being called, and it also prevents postcmd() from being + # called. So we have the first hook, and the second hook, which raised + # the exception + assert app.called_postcmd == 2 + +## +# +# command finalization +# +### +def test_register_cmdfinalization_hook_parameter_count(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_cmdfinalization_hook(app.cmdfinalization_hook_not_enough_parameters) + with pytest.raises(TypeError): + app.register_cmdfinalization_hook(app.cmdfinalization_hook_too_many_parameters) + +def test_register_cmdfinalization_hook_no_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_cmdfinalization_hook(app.cmdfinalization_hook_no_parameter_annotation) + +def test_register_cmdfinalization_hook_wrong_parameter_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_cmdfinalization_hook(app.cmdfinalization_hook_wrong_parameter_annotation) + +def test_register_cmdfinalization_hook_no_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_cmdfinalization_hook(app.cmdfinalization_hook_no_return_annotation) + +def test_register_cmdfinalization_hook_wrong_return_annotation(): + app = PluggedApp() + with pytest.raises(TypeError): + app.register_cmdfinalization_hook(app.cmdfinalization_hook_wrong_return_annotation) + +def test_cmdfinalization(capsys): + app = PluggedApp() + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_cmdfinalization == 0 + + app.register_cmdfinalization_hook(app.cmdfinalization_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_cmdfinalization == 1 + + # register the function again, so it should be called twice + app.reset_counters() + app.register_cmdfinalization_hook(app.cmdfinalization_hook) + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_cmdfinalization == 2 + +def test_cmdfinalization_stop_first(capsys): + app = PluggedApp() + app.register_cmdfinalization_hook(app.cmdfinalization_hook_stop) + app.register_cmdfinalization_hook(app.cmdfinalization_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_cmdfinalization == 2 + assert stop + +def test_cmdfinalization_stop_second(capsys): + app = PluggedApp() + app.register_cmdfinalization_hook(app.cmdfinalization_hook) + app.register_cmdfinalization_hook(app.cmdfinalization_hook_stop) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_cmdfinalization == 2 + assert stop + +def test_cmdfinalization_hook_exception(capsys): + app = PluggedApp() + app.register_cmdfinalization_hook(app.cmdfinalization_hook_exception) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert err + assert app.called_cmdfinalization == 1 + + # register another function, but it shouldn't be called + app.reset_counters() + app.register_cmdfinalization_hook(app.cmdfinalization_hook) + stop = app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert not stop + assert out == 'hello\n' + assert err + assert app.called_cmdfinalization == 1 |