diff options
-rw-r--r-- | CHANGELOG.md | 16 | ||||
-rw-r--r-- | CONTRIBUTING.md | 2 | ||||
-rwxr-xr-x | README.md | 7 | ||||
-rw-r--r-- | cmd2/cmd2.py | 224 | ||||
-rw-r--r-- | cmd2/constants.py | 2 | ||||
-rw-r--r-- | cmd2/parsing.py | 115 | ||||
-rw-r--r-- | cmd2/plugin.py | 27 | ||||
-rw-r--r-- | docs/conf.py | 2 | ||||
-rw-r--r-- | docs/hooks.rst | 318 | ||||
-rw-r--r-- | docs/install.rst | 12 | ||||
-rw-r--r-- | docs/integrating.rst | 25 | ||||
-rwxr-xr-x | setup.py | 4 | ||||
-rw-r--r-- | tests/test_cmd2.py | 18 | ||||
-rw-r--r-- | tests/test_parsing.py | 120 | ||||
-rw-r--r-- | tests/test_plugin.py | 823 |
15 files changed, 1593 insertions, 122 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe148ae..2bda144e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.9.4 (TBD, 2018) +* Bug Fixes + * Fixed bug where ``preparse`` wasn't getting called +* Enhancements + * Improved implementation of lifecycle hooks to to support a plugin + framework, see ``docs/hooks.rst`` for details. + * New dependency on ``attrs`` third party module +* Deprecations + * Deprecated the following hook methods, see ``hooks.rst`` for full details: + * ``cmd2.Cmd.preparse()`` - equivilent functionality available + via ``cmd2.Cmd.register_postparsing_hook()`` + * ``cmd2.Cmd.postparsing_precmd()`` - equivilent functionality available + via ``cmd2.Cmd.register_postparsing_hook()`` + * ``cmd2.Cmd.postparsing_postcmd()`` - equivilent functionality available + via ``cmd2.Cmd.register_postcmd_hook()`` + ## 0.9.3 (July 12, 2018) * Bug Fixes * Fixed bug when StatementParser ``__init__()`` was called with ``terminators`` equal to ``None`` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56cb6ca4..0736d893 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -398,7 +398,7 @@ how to do it. 7. Creating the PR causes our continuous integration (CI) systems to automatically run all of the unit tests on all supported OSes and all supported versions of Python. You should watch your PR - to make sure that all unit tests pass on Both TravisCI (Linux) and AppVeyor (Windows). + to make sure that all unit tests pass on TravisCI (Linux), AppVeyor (Windows), and VSTS (macOS). 8. If any unit tests fail, you should look at the details and fix the failures. You can then push the fix to the same branch in your fork and the PR will automatically get updated and the CI system @@ -3,6 +3,7 @@ cmd2: a tool for building interactive command line apps [](https://pypi.python.org/pypi/cmd2/) [](https://travis-ci.org/python-cmd2/cmd2) [](https://ci.appveyor.com/project/FedericoCeratto/cmd2) +[](https://python-cmd2.visualstudio.com/cmd2/_build/latest?definitionId=1&branch=master) [](https://codecov.io/gh/python-cmd2/cmd2) [](http://cmd2.readthedocs.io/en/latest/?badge=latest) @@ -57,10 +58,12 @@ pip install -U cmd2 ``` cmd2 works with Python 3.4+ on Windows, macOS, and Linux. It is pure Python code with -the only 3rd-party dependencies being on [colorama](https://github.com/tartley/colorama), and [pyperclip](https://github.com/asweigart/pyperclip). +the only 3rd-party dependencies being on [attrs](https://github.com/python-attrs/attrs), +[colorama](https://github.com/tartley/colorama), and [pyperclip](https://github.com/asweigart/pyperclip). Windows has an additional dependency on [pyreadline](https://pypi.python.org/pypi/pyreadline). Non-Windows platforms have an additional dependency on [wcwidth](https://pypi.python.org/pypi/wcwidth). Finally, Python -3.4 has an additional dependency on [contextlib2](https://pypi.python.org/pypi/contextlib2). +3.4 has additional dependencies on [contextlib2](https://pypi.python.org/pypi/contextlib2) and the +[typing](https://pypi.org/project/typing/) backport. For information on other installation options, see [Installation Instructions](https://cmd2.readthedocs.io/en/latest/install.html) in the cmd2 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 06295dfd..875cef59 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -34,15 +34,17 @@ import cmd import collections from colorama import Fore import glob +import inspect import os import platform import re import shlex import sys -from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, 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 @@ -114,7 +116,7 @@ try: except ImportError: # pragma: no cover ipython_available = False -__version__ = '0.9.3' +__version__ = '0.9.4' # optional attribute, when tagged on a function, allows cmd2 to categorize commands @@ -369,6 +371,10 @@ class Cmd(cmd.Cmd): except AttributeError: pass + # 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) @@ -597,12 +603,13 @@ class Cmd(cmd.Cmd): :param msg: message to print to current stdout - anything convertible to a str with '{}'.format() is OK :param end: string appended after the end of the message if not already present, default a newline - :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped + :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped - truncated text is still accessible by scrolling with the right & left arrow keys - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli False -> causes lines longer than the screen width to wrap to the next line - wrapping is ideal when you want to avoid users having to use horizontal scrolling - WARNING: On Windows, the text always wraps regardless of what the chop argument is set to + + WARNING: On Windows, the text always wraps regardless of what the chop argument is set to """ import subprocess if msg is not None and msg != '': @@ -1635,10 +1642,20 @@ class Cmd(cmd.Cmd): # noinspection PyMethodMayBeStatic def preparse(self, raw: str) -> str: - """Hook method executed just before the command line is interpreted, but after the input prompt is generated. + """Hook method executed before user input is parsed. + + WARNING: If it's a multiline command, `preparse()` may not get all the + user input. _complete_statement() really does two things: a) parse the + user input, and b) accept more input in case it's a multiline command + the passed string doesn't have a terminator. `preparse()` is currently + called before we know whether it's a multiline command, and before we + know whether the user input includes a termination character. + + If you want a reliable pre parsing hook method, register a postparsing + hook, modify the user input, and then reparse it. - :param raw: raw command line input - :return: potentially modified raw command line input + :param raw: raw command line input :return: potentially modified raw + command line input """ return raw @@ -1699,12 +1716,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: @@ -1712,23 +1752,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: Optional[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. @@ -1780,7 +1850,7 @@ class Cmd(cmd.Cmd): pipe runs out. We can't refactor it because we need to retain backwards compatibility with the standard library version of cmd. """ - statement = self.statement_parser.parse(line) + statement = self.statement_parser.parse(self.preparse(line)) while statement.multiline_command and not statement.terminator: if not self.quit_on_sigint: try: @@ -3105,6 +3175,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 @@ -3123,8 +3195,134 @@ 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: Callable, count: int): + """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: Callable): + """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 is not None: + raise TypeError("{} must declare return a return type of 'None'".format( + func.__name__, + )) + + def register_preloop_hook(self, func: Callable): + """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: Callable): + """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: Callable): + """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: Callable): + """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: Callable, data_type: 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: Callable): + """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: Callable): + """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: Callable): + """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__)) + + def register_cmdfinalization_hook(self, func: Callable): + """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/constants.py b/cmd2/constants.py index b829000f..d3e8a125 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -15,3 +15,5 @@ REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] # Regular expression to match ANSI escape codes ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') + +LINE_FEED = '\n' diff --git a/cmd2/parsing.py b/cmd2/parsing.py index ddfd5f0d..475554b0 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -5,13 +5,11 @@ import os import re import shlex -from typing import List, Tuple +from typing import List, Tuple, Dict from . import constants from . import utils -LINE_FEED = '\n' - class Statement(str): """String subclass with additional attributes to store the results of parsing. @@ -21,6 +19,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 +56,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 +105,14 @@ 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 +121,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: List[Tuple[str, str]] = None, ): self.allow_redirection = allow_redirection if terminators is None: @@ -226,7 +257,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. @@ -238,15 +269,15 @@ 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: - terminator = LINE_FEED + if line[-1:] == constants.LINE_FEED: + terminator = constants.LINE_FEED command = None args = None 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 @@ -267,7 +298,7 @@ class StatementParser: break if terminator: - if terminator == LINE_FEED: + if terminator == constants.LINE_FEED: terminator_pos = len(tokens)+1 # everything before the first terminator is the command and the args @@ -358,19 +389,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: @@ -420,10 +450,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..dc9ec297 --- /dev/null +++ b/cmd2/plugin.py @@ -0,0 +1,27 @@ +# +# 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/conf.py b/docs/conf.py index 7c3389ac..17f0d4c3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,7 +62,7 @@ author = 'Catherine Devlin and Todd Leonhardt' # The short X.Y version. version = '0.9' # The full version, including alpha/beta/rc tags. -release = '0.9.3' +release = '0.9.4' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/hooks.rst b/docs/hooks.rst index 1e96e963..2a5d7b5f 100644 --- a/docs/hooks.rst +++ b/docs/hooks.rst @@ -1,43 +1,313 @@ -.. 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") + +To retain backwards compatibility with `cmd.Cmd`, after all registered preloop +hooks have been called, the ``preloop()`` method is called. + +A similar approach allows you to register functions to be called 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") + +To retain backwards compatibility with `cmd.Cmd`, after all registered postloop +hooks have been called, the ``postloop()`` method is called. + +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. Call `preparse()` - for backwards compatibility with prior releases of cmd2, now deprecated +4. Parse user input into `Statement` object +5. Call methods registered with `register_postparsing_hook()` +6. Call `postparsing_precmd()` - for backwards compatibility with prior releases of cmd2, now deprecated +7. Redirect output, if user asked for it and it's allowed +8. Start timer +9. Call methods registered with `register_precmd_hook()` +10. Call `precmd()` - for backwards compatibility with ``cmd.Cmd`` +11. Add statement to history +12. Call `do_command` method +13. Call methods registered with `register_postcmd_hook()` +14. Call `postcmd(stop, statement)` - for backwards compatibility with ``cmd.Cmd`` +15. Stop timer and display the elapsed time +16. Stop redirecting output if it was redirected +17. Call methods registered with `register_cmdfinalization_hook()` +18. 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, precommand, 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 command. 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 -- **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 +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 -Command Processing Hooks ------------------------- + +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 postcommand 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 Command Processing Hooks +----------------------------------- Inside the main loop, every time the user hits <Enter> the line is processed by the ``onecmd_plus_hooks`` method. @@ -52,8 +322,4 @@ the various hook methods, presented in chronological order starting with the one .. automethod:: cmd2.cmd2.Cmd.postparsing_precmd -.. automethod:: cmd2.cmd2.Cmd.precmd - -.. automethod:: cmd2.cmd2.Cmd.postcmd - .. automethod:: cmd2.cmd2.Cmd.postparsing_postcmd diff --git a/docs/install.rst b/docs/install.rst index 6baf4078..3578ce25 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -98,13 +98,17 @@ either composition or inheritance to achieve the same goal. This approach will obviously NOT automatically install the required 3rd-party dependencies, so you need to make sure the following Python packages are installed: - * pyparsing + * attrs + * colorama * pyperclip On Windows, there is an additional dependency: * pyreadline +On macOS or Linux, there is an additional dependency: + * wcwidth + Upgrading cmd2 -------------- @@ -122,10 +126,10 @@ If you wish to permanently uninstall ``cmd2``, this can also easily be done with pip uninstall cmd2 -Extra requirement for Python 3.4 --------------------------------- +Extra requirements for Python 3.4 +--------------------------------- ``cmd2`` requires the ``contextlib2`` module for Python 3.4. This is used to temporarily redirect -stdout and stderr. +stdout and stderr. Also when using Python 3.4, ``cmd2`` requires the ``typing`` module backport. Extra requirement for macOS =========================== diff --git a/docs/integrating.rst b/docs/integrating.rst index 56691069..a234173c 100644 --- a/docs/integrating.rst +++ b/docs/integrating.rst @@ -56,15 +56,22 @@ script file. The **onecmd_plus_hooks()** method will do the following to execute a single ``cmd2`` command in a normal fashion: -#. Parse the command line text -#. Execute postparsing_precmd() -#. Add the command to the history -#. Apply output redirection, if present -#. Execute precmd() -#. Execute onecmd() - this is what actually runs the command -#. Execute postcmd() -#. Undo output rediriection (if present) and perform piping, if present -#. Execute postparsing_postcmd() +1. Call `preparse()` - for backwards compatibility with prior releases of cmd2, now deprecated +2. Parse user input into `Statement` object +3. Call methods registered with `register_postparsing_hook()` +4. Call `postparsing_precmd()` - for backwards compatibility with prior releases of cmd2, now deprecated +5. Redirect output, if user asked for it and it's allowed +6. Start timer +7. Call methods registered with `register_precmd_hook()` +8. Call `precmd()` - for backwards compatibility with ``cmd.Cmd`` +9. Add statement to history +10. Call `do_command` method +11. Call methods registered with `register_postcmd_hook()` +12. Call `postcmd(stop, statement)` - for backwards compatibility with ``cmd.Cmd`` +13. Stop timer and display the elapsed time +14. Stop redirecting output if it was redirected +15. Call methods registered with `register_cmdfinalization_hook()` +16. Call `postparsing_postcmd()` - for backwards compatibility - deprecated Running in this fashion enables the ability to integrate with an external event loop. However, how to integrate with any specific event loop is beyond the scope of this documentation. Please note that running in this fashion comes with @@ -5,7 +5,7 @@ Setuptools setup file, used to install or test 'cmd2' """ from setuptools import setup -VERSION = '0.9.3' +VERSION = '0.9.4' DESCRIPTION = "cmd2 - a tool for building interactive command line applications in Python" LONG_DESCRIPTION = """cmd2 is a tool for building interactive command line applications in Python. Its goal is to make it quick and easy for developers to build feature-rich and user-friendly interactive command line applications. It @@ -60,7 +60,7 @@ Programming Language :: Python :: Implementation :: CPython Topic :: Software Development :: Libraries :: Python Modules """.splitlines()))) -INSTALL_REQUIRES = ['pyperclip >= 1.5.27', 'colorama'] +INSTALL_REQUIRES = ['pyperclip >= 1.5.27', 'colorama', 'attrs'] EXTRAS_REQUIRE = { # Windows also requires pyreadline to ensure tab completion works diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 25d1db3f..999aee8c 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -29,7 +29,7 @@ from .conftest import run_cmd, normalize, BASE_HELP, BASE_HELP_VERBOSE, \ def test_ver(): - assert cmd2.__version__ == '0.9.3' + assert cmd2.__version__ == '0.9.4' def test_empty_statement(base_app): @@ -1426,17 +1426,23 @@ optional arguments: @pytest.mark.skipif(sys.platform.startswith('win'), reason="utils.which function only used on Mac and Linux") def test_which_editor_good(): + import platform editor = 'vi' path = utils.which(editor) - # Assert that the vi editor was found because it should exist on all Mac and Linux systems - assert path + + if 'azure' in platform.release().lower(): + # vi doesn't exist on VSTS Hosted Linux agents + assert not path + else: + # Assert that the vi editor was found because it should exist on all Mac and Linux systems + assert path @pytest.mark.skipif(sys.platform.startswith('win'), reason="utils.which function only used on Mac and Linux") def test_which_editor_bad(): - editor = 'notepad.exe' - path = utils.which(editor) - # Assert that the editor wasn't found because no notepad.exe on non-Windows systems ;-) + nonexistent_editor = 'this_editor_does_not_exist.exe' + path = utils.which(nonexistent_editor) + # Assert that the non-existent editor wasn't found assert path is None diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 5dc78745..6e795660 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -26,10 +26,37 @@ def parser(): ) return parser +@pytest.fixture +def default_parser(): + parser = StatementParser() + return parser + +def test_parse_empty_string_default(default_parser): + statement = default_parser.parse('') + assert not statement.command + assert not statement.args + assert statement == '' + assert statement.raw == '' + +@pytest.mark.parametrize('line,tokens', [ + ('command', ['command']), + ('command /* with some comment */ arg', ['command', 'arg']), + ('command arg1 arg2 # comment at the end', ['command', 'arg1', 'arg2']), + ('termbare ; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare & > /tmp/output', ['termbare', '&', '>', '/tmp/output']), + ('termbare& > /tmp/output', ['termbare&', '>', '/tmp/output']), + ('help|less', ['help', '|', 'less']), +]) +def test_tokenize_default(default_parser, line, tokens): + tokens_to_test = default_parser.tokenize(line) + assert tokens_to_test == tokens + 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 +98,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 +111,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 +125,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 +144,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 +152,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,6 +178,7 @@ 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_c_comment_no_closing(parser): statement = parser.parse('cat /tmp/*.txt') @@ -160,8 +198,9 @@ 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', @@ -170,7 +209,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'] @@ -179,6 +219,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 @@ -188,6 +229,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'] @@ -201,7 +243,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' @@ -210,6 +253,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' @@ -219,6 +263,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' @@ -228,6 +273,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' @@ -237,6 +283,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' @@ -249,6 +296,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 == '>>' @@ -260,6 +308,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 == ';' @@ -277,6 +326,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 @@ -286,6 +336,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 @@ -300,6 +351,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 @@ -320,6 +372,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 == ';' @@ -329,6 +382,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' @@ -338,6 +392,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' @@ -346,6 +401,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): @@ -353,6 +409,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): @@ -360,6 +417,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é' @@ -389,6 +447,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' @@ -396,6 +458,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', [ @@ -407,7 +470,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' @@ -418,7 +482,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', [ @@ -432,7 +497,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): @@ -440,6 +506,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): @@ -448,9 +515,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 @@ -459,6 +526,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): @@ -466,12 +534,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): @@ -479,6 +549,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', [ @@ -509,5 +580,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..e401e837 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,823 @@ +# 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_preparse = 0 + 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 + + ### + # + # preparse hook + # + ### + def preparse(self, line: str) -> str: + "Preparsing hook" + self.called_preparse += 1 + return line + + ### + # + # 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 preparse hook +# +### +def test_preparse(capsys): + app = PluggedApp() + app.onecmd_plus_hooks('say hello') + out, err = capsys.readouterr() + assert out == 'hello\n' + assert not err + assert app.called_preparse == 1 + +### +# +# 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 |