diff --git a/cliff/command.py b/cliff/command.py index 643950db..ae66e0e2 100644 --- a/cliff/command.py +++ b/cliff/command.py @@ -14,6 +14,7 @@ import abc import inspect import six +from stevedore import extension from cliff import _argparse @@ -22,6 +23,12 @@ from cliff import _argparse class Command(object): """Base class for command plugins. + When the command is instantiated, it loads extensions from a + namespace based on the parent application namespace and the + command name:: + + app.namespace + '.' + cmd_name.replace(' ', '_') + :param app: Application instance invoking the command. :paramtype app: cliff.app.App @@ -36,6 +43,26 @@ class Command(object): self.app = app self.app_args = app_args self.cmd_name = cmd_name + self._load_hooks() + + def _load_hooks(self): + # Look for command extensions + if self.app and self.cmd_name: + namespace = '{}.{}'.format( + self.app.command_manager.namespace, + self.cmd_name.replace(' ', '_') + ) + self._hooks = extension.ExtensionManager( + namespace=namespace, + invoke_on_load=True, + invoke_kwds={ + 'command': self, + }, + ) + else: + # Setting _hooks to an empty list allows iteration without + # checking if there are hooks every time. + self._hooks = [] return def get_description(self): @@ -73,6 +100,8 @@ class Command(object): prog=prog_name, formatter_class=_SmartHelpFormatter, ) + for hook in self._hooks: + hook.obj.get_parser(parser) return parser @abc.abstractmethod diff --git a/cliff/hooks.py b/cliff/hooks.py new file mode 100644 index 00000000..e8a804ae --- /dev/null +++ b/cliff/hooks.py @@ -0,0 +1,38 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc + +import six + + +@six.add_metaclass(abc.ABCMeta) +class CommandHook(object): + """Base class for command hooks. + + :param app: Command instance being invoked + :paramtype app: cliff.command.Command + + """ + + def __init__(self, command): + self.cmd = command + + @abc.abstractmethod + def get_parser(self, parser): + """Return an :class:`argparse.ArgumentParser`. + + :param parser: An existing ArgumentParser instance to be modified. + :paramtype parser: ArgumentParser + :returns: ArgumentParser + """ + return parser diff --git a/cliff/tests/test_command_hooks.py b/cliff/tests/test_command_hooks.py new file mode 100644 index 00000000..bacc3eba --- /dev/null +++ b/cliff/tests/test_command_hooks.py @@ -0,0 +1,107 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cliff import app as application +from cliff import command +from cliff import commandmanager +from cliff import hooks +from cliff.tests import base + +import mock +from stevedore import extension + + +def make_app(**kwargs): + cmd_mgr = commandmanager.CommandManager('cliff.tests') + + # Register a command that succeeds + cmd = mock.MagicMock(spec=command.Command) + command_inst = mock.MagicMock(spec=command.Command) + command_inst.run.return_value = 0 + command.return_value = command_inst + cmd_mgr.add_command('mock', cmd) + + # Register a command that fails + err_command = mock.Mock(name='err_command', spec=command.Command) + err_command_inst = mock.Mock(spec=command.Command) + err_command_inst.run = mock.Mock( + side_effect=RuntimeError('test exception') + ) + err_command.return_value = err_command_inst + cmd_mgr.add_command('error', err_command) + + app = application.App('testing command hooks', + '1', + cmd_mgr, + stderr=mock.Mock(), # suppress warning messages + **kwargs + ) + return app + + +class TestCommand(command.Command): + """Description of command. + """ + + def get_parser(self, prog_name): + parser = super(TestCommand, self).get_parser(prog_name) + return parser + + def take_action(self, parsed_args): + return 42 + + +class TestHook(hooks.CommandHook): + + def get_parser(self, parser): + parser.add_argument('--added-by-hook') + return parser + + +class TestCommandLoadHooks(base.TestBase): + + def test_no_app_or_name(self): + cmd = TestCommand(None, None) + self.assertEqual([], cmd._hooks) + + @mock.patch('stevedore.extension.ExtensionManager') + def test_app_and_name(self, em): + app = make_app() + TestCommand(app, None, cmd_name='test') + print(em.mock_calls[0]) + name, args, kwargs = em.mock_calls[0] + print(kwargs) + self.assertEqual('cliff.tests.test', kwargs['namespace']) + + +class TestParserHook(base.TestBase): + + def setUp(self): + super(TestParserHook, self).setUp() + self.app = make_app() + self.cmd = TestCommand(self.app, None, cmd_name='test') + self.hook = TestHook(self.cmd) + self.mgr = extension.ExtensionManager.make_test_instance( + [extension.Extension( + 'parser-hook', + None, + None, + self.hook)], + ) + # Replace the auto-loaded hooks with our explicitly created + # manager. + self.cmd._hooks = self.mgr + + def test_get_parser(self): + parser = self.cmd.get_parser('test') + results = parser.parse_args(['--added-by-hook', 'value']) + self.assertEqual(results.added_by_hook, 'value') diff --git a/demoapp/cliffdemo/hook.py b/demoapp/cliffdemo/hook.py new file mode 100644 index 00000000..09c546c8 --- /dev/null +++ b/demoapp/cliffdemo/hook.py @@ -0,0 +1,41 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from cliff.command import Command +from cliff.hooks import CommandHook + + +class Hooked(Command): + "A command to demonstrate how the hooks work" + + log = logging.getLogger(__name__) + + def take_action(self, parsed_args): + self.app.stdout.write('this command has an extension') + + +class Hook(CommandHook): + """Hook sample for the 'hooked' command. + + This would normally be provided by a separate package from the + main application, but is included in the demo app for simplicity. + + """ + + def get_parser(self, parser): + print('sample hook get_parser()') + parser.add_argument('--added-by-hook') + return parser diff --git a/demoapp/setup.py b/demoapp/setup.py index dd8695d4..1cd2ce42 100644 --- a/demoapp/setup.py +++ b/demoapp/setup.py @@ -60,6 +60,10 @@ setup( 'file = cliffdemo.show:File', 'show file = cliffdemo.show:File', 'unicode = cliffdemo.encoding:Encoding', + 'hooked = cliffdemo.hook:Hooked', + ], + 'cliff.demo.hooked': [ + 'sample-hook = cliffdemo.hook:Hook', ], }, diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index 5b67aa18..38abc7ed 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -29,6 +29,12 @@ Command .. autoclass:: cliff.command.Command :members: +CommandHook +----------- + +.. autoclass:: cliff.hooks.CommandHook + :members: + ShowOne ------- diff --git a/doc/source/user/demoapp.rst b/doc/source/user/demoapp.rst index 9dd7b8a4..0edcdbfe 100644 --- a/doc/source/user/demoapp.rst +++ b/doc/source/user/demoapp.rst @@ -243,7 +243,6 @@ output formatter and printing the data to the console. | Modified Time | 1335569964.0 | +---------------+--------------+ - setup.py -------- @@ -260,3 +259,40 @@ command namespace so that it only loads the command plugins that it should be managing. .. _distribute: http://packages.python.org/distribute/ + +Command Extension Hooks +======================= + +Individual subcommands of an application can be extended via hooks +registered as separate plugins. In the demo application, the +``hooked`` command has a single extension registered. + +The namespace for hooks is a combination of the application namespace +and the command name. In this case, the application namespace is +``cliff.demo`` and the command is ``hooked``, so the extension +namespace is ``cliff.demo.hooked``. If the subcommand name includes +spaces, they are replaced with underscores ("``_``") to build the +namespace. + +.. literalinclude:: ../../../demoapp/cliffdemo/hook.py + :linenos: + +Although the ``hooked`` command does not add any arguments to the +parser it creates, the help output shows that the extension adds a +single ``--added-by-hook`` option. + +:: + + (.venv)$ cliffdemo hooked -h + sample hook get_parser() + usage: cliffdemo hooked [-h] [--added-by-hook ADDED_BY_HOOK] + + A command to demonstrate how the hooks work + + optional arguments: + -h, --help show this help message and exit + --added-by-hook ADDED_BY_HOOK + +.. seealso:: + + :class:`cliff.hooks.CommandHook` -- The API for command hooks. diff --git a/doc/source/user/introduction.rst b/doc/source/user/introduction.rst index e0e96159..e9a14ff0 100644 --- a/doc/source/user/introduction.rst +++ b/doc/source/user/introduction.rst @@ -19,7 +19,7 @@ fit. Cliff Objects ============= -Cliff is organized around four objects that are combined to create a +Cliff is organized around five objects that are combined to create a useful command line program. The Application @@ -50,6 +50,18 @@ responsible for taking action based on instructions from the user. It defines its own local argument parser (usually using argparse_) and a :func:`take_action` method that does the appropriate work. +The CommandHook +--------------- + +The :class:`cliff.hooks.CommandHook` class can extend a Command by +modifying the command line arguments available, for example to add +options used by a driver. Each CommandHook subclass must implement the +full hook API, defined by the base class. Extensions should be +registered using an entry point namespace based on the application +namespace and the command name:: + + application_namespace + '.' + command_name.replace(' ', '_') + The Interactive Application ---------------------------