diff options
-rw-r--r-- | cmd2/cmd2.py | 27 | ||||
-rw-r--r-- | docs/hooks.rst | 178 | ||||
-rw-r--r-- | tests/test_plugin.py | 20 |
3 files changed, 181 insertions, 44 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a694b3ae..e0bd1493 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1688,7 +1688,7 @@ class Cmd(cmd.Cmd): statement = self.statement_parser.parse_command_only(line) return statement.command, statement.args, statement.command_and_args - def onecmd_plus_hooks(self, line): + def onecmd_plus_hooks(self, line: str) -> bool: """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. :param line: str - line of text read from input @@ -1706,7 +1706,9 @@ class Cmd(cmd.Cmd): if not stop: (stop, statement) = self.postparsing_precmd(statement) if stop: - return self.postparsing_postcmd(stop) + # we need to not run the command, but + # we need to run the finalization hooks + raise EmptyStatement try: if self.allow_redirection: @@ -1714,9 +1716,20 @@ class Cmd(cmd.Cmd): timestart = datetime.datetime.now() if self._in_py: self._last_result = None + + # precommand hooks + for func in self._precmd_hooks: + statement = func(statement) statement = self.precmd(statement) + + # go run the command function stop = self.onecmd(statement) + + # postcommand hooks + for func in self._postcmd_hooks: + stop = func(stop, statement) stop = self.postcmd(stop, statement) + if self.timing: self.pfeedback('Elapsed: %s' % str(datetime.datetime.now() - timestart)) finally: @@ -3114,6 +3127,8 @@ Script should contain one command per line, just like command would be typed in self._preloop_hooks = [] self._postloop_hooks = [] self._postparsing_hooks = [] + self._precmd_hooks = [] + self._postcmd_hooks = [] def register_preloop_hook(self, func): """Register a function to be called at the beginning of the command loop.""" @@ -3127,6 +3142,14 @@ Script should contain one command per line, just like command would be typed in """Register a function to be called after parsing user input but before running the command""" self._postparsing_hooks.append(func) + def register_precmd_hook(self, func): + """Register a function to be called before the command function.""" + self._precmd_hooks.append(func) + + def register_postcmd_hook(self, func): + """Register a function to be called after the command function.""" + self._postcmd_hooks.append(func) + class HistoryItem(str): """Class used to represent an item in the History list. diff --git a/docs/hooks.rst b/docs/hooks.rst index b2553744..7141c729 100644 --- a/docs/hooks.rst +++ b/docs/hooks.rst @@ -40,15 +40,16 @@ And also after the command loop has finished:: def myhookmethod(self): self.poutput("before the loop begins") -As you can see the preloop and postloop hook methods are not passed any -parameters and any return value is ignored. +Preloop and postloop hook methods are not passed any parameters and any return +value is ignored. 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 @@ -70,16 +71,16 @@ until the application exits: 4. Call methods registered with `register_postparsing_hook()` 5. Call `postparsing_precmd()` - for backwards compatibility deprecated 6. Redirect output, if user asked for it and it's allowed -7. Start command timer +7. Start timer 8. Call methods registered with `register_precmd_hook()` -9. Call `precmd()` - for backwards compatibility deprecated +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()` - for backwards compatibility deprecated -14. Stop timer -15. Stop redirecting output, if it was redirected -16. Call methods registered with `register_cmdcompleted_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 @@ -89,12 +90,32 @@ 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 ^^^^^^^^^^^^^^^^^ -You can register one or more methods which are called after the user input -has been parsed, but before output is redirected, the timer is started, and -before the command is run. +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 +- 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:: @@ -106,38 +127,125 @@ To define and register a postparsing hook, do the following:: def myhookmethod(self, statement): return False, statement -The hook method will be passed one parameter, a `Statement` object containing -the parsed user input. There are many useful attributes in the Statement -object, including `.raw` which contains exactly what the user typed. The hook +The hook method will be passed one parameter, a ``Statement`` object containing +the parsed user input. There are many useful attributes in the ``Statement`` +object, including ``.raw`` which contains exactly what the user typed. The hook method must return a tuple: the first element indicates whether to fatally fail -this command and exit the application, and the second element is a potentially -modified `Statement` object. +this command prior to execution and exit the application, and the second element +is a potentially modified ``Statement`` object. + +To modify the user input, you create and return a new ``Statement`` object. +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, statement): + stop = False + if not '|' in statement.raw: + newinput = statement.raw + ' | less' + statement = self.statement_parser.parse(newinput) + return stop, statement + +If a postparsing hook returns ``True`` as the first value in the tuple: +- 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 -To modify the user input, you create and return a new `Statement` object:: - def myhookmethod(self, statement): - if not '|' in statement.raw: - newinput = statement.raw + ' | less' - statement = self.statement_parser.parse(newinput) - return False, statement +Precommand Hooks +^^^^^^^^^^^^^^^^^ -There are several other mechanisms for controlling the flow of command -processing. If you raise an `cmd2.EmptyStatement` exception, no further -postparsing hooks will be run, nor will the command be run. No error will -be displayed for the user either. +Once output is redirected and the timer started, all the hooks registered with +``register_precmd_hook()`` are called. Here's how you do it:: -If you raise any other exception, no further postprocessing hooks will be run, -nor will the command be executed. The exception message will be displayed for -the user. + class App(cmd2.Cmd): + def __init__(self, *args, *kwargs): + super().__init__(*args, **kwargs) + self.register_precmd_hook(self.myhookmethod) -Precommand Hooks -^^^^^^^^^^^^^^^^^ + def myhookmethod(self, statement): + return statement + +You may choose to create a new ``Statement`` with different properties (see +above) or leave it alone, but you must return a ``Statement`` object. + +After all registered precommand hooks have been called, ``self.precmd(statement)`` +will be called. This retains full backwards compatibility with ``cmd.Cmd``. Postcommand Hooks ^^^^^^^^^^^^^^^^^^ -Command Completed 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 a 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, statement): + stop = False + return stop + +Your hook will be passed the statement object, which 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, no further postcommand hook methods +will be called. + +After all registered precommand hooks have been called, +``self.postcmd(statement)`` will be called. This retains full backwards +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. + +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 + +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 determin 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 --------------------------------------------- diff --git a/tests/test_plugin.py b/tests/test_plugin.py index a6fc8fc1..33c3855b 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -165,17 +165,19 @@ def test_postparsing_hook_stop_second(capsys): def test_postparsing_hook_emptystatement_first(capsys): app = PluggedApp() app.register_postparsing_hook(app.pph_emptystatement) - app.onecmd_plus_hooks('say hello') + stop = app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() + assert not stop assert not out assert not err assert app.called_pph == 1 # register another function but it shouldn't be called app.reset_counters() - app.register_postparsing_hook(app.pph) + stop = app.register_postparsing_hook(app.pph) app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() + assert not stop assert not out assert not err assert app.called_pph == 1 @@ -183,8 +185,9 @@ def test_postparsing_hook_emptystatement_first(capsys): def test_postparsing_hook_emptystatement_second(capsys): app = PluggedApp() app.register_postparsing_hook(app.pph) - app.onecmd_plus_hooks('say hello') + stop = app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() + assert not stop assert out == 'hello\n' assert not err assert app.called_pph == 1 @@ -192,8 +195,9 @@ def test_postparsing_hook_emptystatement_second(capsys): # register another function and make sure it gets called app.reset_counters() app.register_postparsing_hook(app.pph_emptystatement) - app.onecmd_plus_hooks('say hello') + stop = app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() + assert not stop assert not out assert not err assert app.called_pph == 2 @@ -203,25 +207,27 @@ def test_postparsing_hook_emptystatement_second(capsys): app.register_postparsing_hook(app.pph) stop = app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() + assert not stop assert not out assert not err assert app.called_pph == 2 - assert not stop def test_postparsing_hook_exception(capsys): app = PluggedApp() app.register_postparsing_hook(app.pph_exception) - app.onecmd_plus_hooks('say hello') + stop = app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() + assert not stop assert not out assert err assert app.called_pph == 1 # register another function, but it shouldn't be called app.reset_counters() - app.register_postparsing_hook(app.pph) + stop = app.register_postparsing_hook(app.pph) app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() + assert not stop assert not out assert err assert app.called_pph == 1 |