add hook for manipulating the argument parser

Update Commands to load a separate set of extensions to be used as
"hooks," triggered at different points in the processing of the
command. Start with a hook that is given access to the argument parser
for the command so it can modify it.

Change-Id: I0785548fd36a61cda616921a4a21be3f67701300
Signed-off-by: Doug Hellmann <doug@doughellmann.com>
This commit is contained in:
Doug Hellmann 2017-05-25 18:34:10 -04:00
parent decac4318e
commit e72e54e757
8 changed files with 275 additions and 2 deletions

View File

@ -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

38
cliff/hooks.py Normal file
View File

@ -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

View File

@ -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')

41
demoapp/cliffdemo/hook.py Normal file
View File

@ -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

View File

@ -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',
],
},

View File

@ -29,6 +29,12 @@ Command
.. autoclass:: cliff.command.Command
:members:
CommandHook
-----------
.. autoclass:: cliff.hooks.CommandHook
:members:
ShowOne
-------

View File

@ -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.

View File

@ -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
---------------------------