summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorkotfu <kotfu@kotfu.net>2018-06-22 16:26:59 -0600
committerkotfu <kotfu@kotfu.net>2018-06-22 16:26:59 -0600
commita73c061b82ac1824db8e1747698c90c0ae7766b1 (patch)
tree1ea8bd58ddd885776e91ed79dec41fb69b5a169a
parent1cd46fd53c698f062995c2c45db57c07285a6f46 (diff)
downloadcmd2-git-a73c061b82ac1824db8e1747698c90c0ae7766b1.tar.gz
Postcommand hooks implemented
-rw-r--r--cmd2/cmd2.py31
-rw-r--r--cmd2/plugin.py5
-rw-r--r--docs/hooks.rst23
-rw-r--r--tests/test_plugin.py198
4 files changed, 207 insertions, 50 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index bd56fe71..0bb7509f 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -1726,8 +1726,12 @@ class Cmd(cmd.Cmd):
stop = self.onecmd(statement)
# postcommand hooks
+ data = plugin.PostcommandData(stop, statement)
for func in self._postcmd_hooks:
- stop = func(stop, statement)
+ 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:
@@ -1738,6 +1742,7 @@ class Cmd(cmd.Cmd):
except EmptyStatement:
pass
except ValueError as ex:
+ # TODO move this except to way above, so ValueError is only caught for shlex errors
# 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:
@@ -3187,38 +3192,42 @@ Script should contain one command per line, just like command would be typed in
self._validate_postparsing_callable(func)
self._postparsing_hooks.append(func)
- def register_precmd_hook(self, func):
- """Register a function to be called before the command function."""
+ @classmethod
+ def _validate_prepostcmd_hook(cls, func, data_type):
signature = inspect.signature(func)
# validate that the callable has the right number of parameters
- self._validate_callable_param_count(func, 1)
+ 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 != plugin.PrecommandData:
+ if param.annotation != data_type:
raise TypeError('argument 1 of {} has incompatible type {}, expected {}'.format(
func.__name__,
param.annotation,
- plugin.PrecommandData,
+ 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__,
- plugin.PrecommandData,
+ data_type,
))
- if signature.return_annotation != plugin.PrecommandData:
+ if signature.return_annotation != data_type:
raise TypeError('{} has incompatible return type {}, expected {}'.format(
func.__name__,
signature.return_annotation,
- plugin.PrecommandData,
+ data_type,
))
+
+ def register_precmd_hook(self, func):
+ """Register a hook to be called before the command function."""
+ self._validate_prepostcmd_hook(func, plugin.PrecommandData)
self._precmd_hooks.append(func)
def register_postcmd_hook(self, func):
- """Register a function to be called after the command function."""
+ """Register a hook to be called after the command function."""
+ self._validate_prepostcmd_hook(func, plugin.PostcommandData)
self._postcmd_hooks.append(func)
- # TODO check signature of registered func and throw error if it's wrong
class History(list):
diff --git a/cmd2/plugin.py b/cmd2/plugin.py
index 47f4a514..377012b8 100644
--- a/cmd2/plugin.py
+++ b/cmd2/plugin.py
@@ -14,8 +14,9 @@ class PrecommandData():
@attr.s
class PostcommandData():
- stop = attr.ib(default=False)
+ stop = attr.ib()
+ statement = attr.ib()
@attr.s
class CommandFinalizationData():
- stop = attr.ib(default=False)
+ stop = attr.ib()
diff --git a/docs/hooks.rst b/docs/hooks.rst
index 4c15e90c..fd140331 100644
--- a/docs/hooks.rst
+++ b/docs/hooks.rst
@@ -196,10 +196,10 @@ Once output is redirected and the timer started, all the hooks registered with
super().__init__(*args, **kwargs)
self.register_precmd_hook(self.myhookmethod)
- def myhookmethod(self, params: cmd2.plugin.PrecommandData) -> cmd2.plugin.PrecommandData:
+ def myhookmethod(self, data: cmd2.plugin.PrecommandData) -> cmd2.plugin.PrecommandData:
# the statement object created from the user input
- # is available as params.statement
- return params
+ # 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
@@ -208,7 +208,7 @@ 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 ``params.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.
@@ -227,22 +227,23 @@ 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::
+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, stop, statement):
+ def myhookmethod(self, data: cmd2.plugin.PostcommandData) -> cmd2.plugin.PostcommandData:
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.
+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, no further postcommand hook methods
-will be called.
+If any postcommand hook raises an exception, the exception will be displayed to the user, and no further postcommand hook methods
+will be called. Command finalization hooks, if any, will be called.
After all registered precommand hooks have been called,
``self.postcmd(statement)`` will be called to retain full backward compatibility
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 1f07f177..cc60ce2a 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -24,6 +24,7 @@ class Plugin:
def reset_counters(self):
self.called_postparsing = 0
self.called_precmd = 0
+ self.called_postcmd = 0
###
#
@@ -43,7 +44,7 @@ class Plugin:
"A preloop or postloop hook with too many parameters"
pass
- def prepost_hook_with_wrong_return_type(self) -> bool:
+ def prepost_hook_with_wrong_return_annotation(self) -> bool:
"A preloop or postloop hook with incorrect return type"
pass
@@ -77,19 +78,19 @@ class Plugin:
"A postparsing hook with too many parameters"
pass
- def postparse_hook_undeclared_parameter_type(self, data) -> cmd2.plugin.PostparsingData:
+ 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_type(self, data: str) -> cmd2.plugin.PostparsingData:
+ 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_type(self, data: cmd2.plugin.PostparsingData):
+ 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_type(self, data: cmd2.plugin.PostparsingData) -> str:
+ def postparse_hook_wrong_return_annotation(self, data: cmd2.plugin.PostparsingData) -> str:
"A postparsing hook with the wrong return type"
pass
@@ -126,21 +127,63 @@ class Plugin:
"A precommand hook with too many parameters"
return one
- def precmd_hook_no_parameter_type(self, data) -> plugin.PrecommandData:
+ 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_type(self, data: str) -> plugin.PrecommandData:
+ 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_type(self, data: plugin.PrecommandData):
+ 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_type(self, data: plugin.PrecommandData) -> cmd2.Statement:
+ 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')
class PluggedApp(Plugin, cmd2.Cmd):
"A sample app with a plugin mixed in"
@@ -161,10 +204,10 @@ def test_register_preloop_hook_too_many_parameters():
with pytest.raises(TypeError):
app.register_preloop_hook(app.prepost_hook_too_many_parameters)
-def test_register_preloop_hook_with_return_type():
+def test_register_preloop_hook_with_return_annotation():
app = PluggedApp()
with pytest.raises(TypeError):
- app.register_preloop_hook(app.prepost_hook_with_wrong_return_type)
+ app.register_preloop_hook(app.prepost_hook_with_wrong_return_annotation)
def test_preloop_hook(capsys):
app = PluggedApp()
@@ -192,10 +235,10 @@ def test_register_postloop_hook_too_many_parameters():
with pytest.raises(TypeError):
app.register_postloop_hook(app.prepost_hook_too_many_parameters)
-def test_register_postloop_hook_with_wrong_return_type():
+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_type)
+ app.register_postloop_hook(app.prepost_hook_with_wrong_return_annotation)
def test_postloop_hook(capsys):
app = PluggedApp()
@@ -228,25 +271,25 @@ def test_postparsing_hook_too_many_parameters():
with pytest.raises(TypeError):
app.register_postparsing_hook(app.postparse_hook_too_many_parameters)
-def test_postparsing_hook_undeclared_parameter_type():
+def test_postparsing_hook_undeclared_parameter_annotation():
app = PluggedApp()
with pytest.raises(TypeError):
- app.register_postparsing_hook(app.postparse_hook_undeclared_parameter_type)
+ app.register_postparsing_hook(app.postparse_hook_undeclared_parameter_annotation)
-def test_postparsing_hook_wrong_parameter_type():
+def test_postparsing_hook_wrong_parameter_annotation():
app = PluggedApp()
with pytest.raises(TypeError):
- app.register_postparsing_hook(app.postparse_hook_wrong_parameter_type)
+ app.register_postparsing_hook(app.postparse_hook_wrong_parameter_annotation)
-def test_postparsing_hook_undeclared_return_type():
+def test_postparsing_hook_undeclared_return_annotation():
app = PluggedApp()
with pytest.raises(TypeError):
- app.register_postparsing_hook(app.postparse_hook_undeclared_return_type)
+ app.register_postparsing_hook(app.postparse_hook_undeclared_return_annotation)
-def test_postparsing_hook_wrong_return_type():
+def test_postparsing_hook_wrong_return_annotation():
app = PluggedApp()
with pytest.raises(TypeError):
- app.register_postparsing_hook(app.postparse_hook_wrong_return_type)
+ app.register_postparsing_hook(app.postparse_hook_wrong_return_annotation)
def test_postparsing_hook(capsys):
app = PluggedApp()
@@ -393,22 +436,22 @@ def test_register_precmd_hook_parameter_count():
def test_register_precmd_hook_no_parameter_annotation():
app = PluggedApp()
with pytest.raises(TypeError):
- app.register_precmd_hook(app.precmd_hook_no_parameter_type)
+ 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_type)
+ 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_type)
+ 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_type)
+ app.register_precmd_hook(app.precmd_hook_wrong_return_annotation)
def test_precmd_hook(capsys):
app = PluggedApp()
@@ -504,8 +547,111 @@ def test_precmd_hook_emptystatement_second(capsys):
# 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):
- pass
+ 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
##
#