diff options
-rw-r--r-- | cmd2/cmd2.py | 30 | ||||
-rw-r--r-- | docs/hooks.rst | 33 | ||||
-rw-r--r-- | tests/test_plugin.py | 59 |
3 files changed, 108 insertions, 14 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 69d6224e..89f14b37 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3127,8 +3127,36 @@ Script should contain one command per line, just like command would be typed in def register_precmd_hook(self, func): """Register a function to be called before the command function.""" + import inspect + signature = inspect.signature(func) + # validate that the callable has the right number of parameters + nparam = len(signature.parameters) + if nparam < 1: + raise TypeError('precommand hooks must have one positional argument') + if nparam > 1: + raise TypeError('precommand hooks take one positional argument but {} were given'.format(nparam)) + # validate the parameter has the right annotation + paramname = list(signature.parameters.keys())[0] + param = signature.parameters[paramname] + if param.annotation != plugin.PrecommandData: + raise TypeError('argument 1 of {} has incompatible type {}, expected {}'.format( + func.__name__, + param.annotation, + plugin.PrecommandData, + )) + # 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, + )) + if signature.return_annotation != plugin.PrecommandData: + raise TypeError('{} has incompatible return type {}, expected {}'.format( + func.__name__, + signature.return_annotation, + plugin.PrecommandData, + )) self._precmd_hooks.append(func) - # TODO check signature of registered func and throw error if it's wrong def register_postcmd_hook(self, func): """Register a function to be called after the command function.""" diff --git a/docs/hooks.rst b/docs/hooks.rst index 47909abf..1efb2c3a 100644 --- a/docs/hooks.rst +++ b/docs/hooks.rst @@ -166,28 +166,39 @@ If a postparsing hook returns ``True`` as the first value in the tuple: Precommand Hooks ^^^^^^^^^^^^^^^^^ -A precommand hook is defined in ``cmd.Cmd``. It is not able to request that the -app terminate, but it is passed the user input and allowed to make changes. If -your hook needs to be able to exit the application, you should implement it as a -postparsing hook. +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 you do it:: +``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, statement): - return statement + def myhookmethod(self, data: plugin.PrecommandData) -> 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 create a new ``Statement`` with different properties (see -above) or leave it alone, but you must return a ``Statement`` object. +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``. In any case, you must return a ``PrecommandData`` +object. You don't have to create this object from scratch, you can just return +the one given to you after you make modifications. After all registered precommand hooks have been called, -``self.precmd(statement)`` will be called. This retains full backwards -compatibility with ``cmd.Cmd``. +``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 ^^^^^^^^^^^^^^^^^^ diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 133284a0..26eb88bb 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -53,6 +53,11 @@ class Plugin: self.called_postparsing += 1 raise ValueError + ### + # + # precommand hooks, some valid, some invalid + # + ### def precmd(self, statement: cmd2.Statement) -> cmd2.Statement: "Override cmd.Cmd method" self.called_precmd += 1 @@ -63,16 +68,39 @@ class Plugin: self.called_precmd += 1 return data - def precmd_hook_emptystatement(self, statement: cmd2.Statement) -> cmd2.Statement: + 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, statement: cmd2.Statement) -> cmd2.Statement: + 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, mydat) -> plugin.PrecommandData: + "A precommand hook with no type annotation on the parameter" + return mydat + + def precmd_hook_wrong_parameter_annotation(self, mydat: str) -> plugin.PrecommandData: + "A precommand hook with the incorrect type annotation on the parameter" + return mydat + + def precmd_hook_no_return_annotation(self, mydat: plugin.PrecommandData): + "A precommand hook with no type annotation on the return value" + return mydat + + def precmd_hook_wrong_return_annotation(self, mydat: plugin.PrecommandData) -> cmd2.Statement: + return self.statement_parser.parse('hi there') + class PluggedApp(Plugin, cmd2.Cmd): "A sample app with a plugin mixed in" @@ -270,6 +298,33 @@ def test_postparsing_hook_exception(capsys): # 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') |