Client command extension support
Adds extension support with emphasis on ease of extension creation. Extensions strongly conform to preexisting neutron commands (/neutron/v2_0/*). A sample extension has been included (/neutron/v2_0/contrib/_fox_sockets.py). As it is assumed that the sample extension will be packaged with the client, small changes were required to include it with the unit tests. It is also possible to install a module with a 'neutronclient.extension' entry- point defined. More information on this can be found in the stevedore docs under the section "Loading the Plugins". Extension discovery is modeled after nova's module discovery but deviates strongly beyond that. A conforming module, at a minimum: * Will have a class that subclasses NeutronClientExtension to provide the requisite version support, paths, and variable names for the client. Example: neutronclient.neutron.v2_0.contrib._fox_sockets.FoxInSocket * Will have at least one class that subclasses from the ClientExtension* classes to provide the new functionality to the client Example: neutronclient.neutron.v2_0.contrib._fox_sockets.FoxInSocketsList * ClientExtension* subclasses must have a shell_command class variable if the command is to be available to the CLI (shell.py) Example: neutronclient.neutron.v2_0.contrib._fox_sockets.FoxInSocketsList Provides client command extensions through new classes: NeutronClientExtension, and ClientExtension<Action>. The precedence of command loading are as follows: * hard coded commands are loaded first * contribued commands (those in /contrib) * external commands (installed in the environment) are loaded last Commands that have the same name will be overwritten by commands that are loaded later. To greatly change the execution of a command for your particular extension you only need to override the execute method. Currently this extension support is limited to top-level resources. Parent/ child relationships may be added if desired. Change-Id: I5b2fe530c90b5ce1243fc10341d6d434a1ecea7a Implements: blueprint extensible-neutronclient
This commit is contained in:
parent
b9a7d52ecb
commit
779b02e480
86
neutronclient/common/extension.py
Normal file
86
neutronclient/common/extension.py
Normal file
@ -0,0 +1,86 @@
|
||||
# Copyright 2015 Rackspace Hosting Inc.
|
||||
# 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.
|
||||
#
|
||||
from stevedore import extension
|
||||
|
||||
from neutronclient.neutron import v2_0 as neutronV20
|
||||
|
||||
|
||||
def _discover_via_entry_points():
|
||||
emgr = extension.ExtensionManager('neutronclient.extension',
|
||||
invoke_on_load=False)
|
||||
return ((ext.name, ext.plugin) for ext in emgr)
|
||||
|
||||
|
||||
class NeutronClientExtension(neutronV20.NeutronCommand):
|
||||
pagination_support = False
|
||||
_formatters = {}
|
||||
sorting_support = False
|
||||
|
||||
|
||||
class ClientExtensionShow(NeutronClientExtension, neutronV20.ShowCommand):
|
||||
def get_data(self, parsed_args):
|
||||
# NOTE(mdietz): Calls 'execute' to provide a consistent pattern
|
||||
# for any implementers adding extensions with
|
||||
# regard to any other extension verb.
|
||||
return self.execute(parsed_args)
|
||||
|
||||
def execute(self, parsed_args):
|
||||
return super(ClientExtensionShow, self).get_data(parsed_args)
|
||||
|
||||
|
||||
class ClientExtensionList(NeutronClientExtension, neutronV20.ListCommand):
|
||||
|
||||
def get_data(self, parsed_args):
|
||||
# NOTE(mdietz): Calls 'execute' to provide a consistent pattern
|
||||
# for any implementers adding extensions with
|
||||
# regard to any other extension verb.
|
||||
return self.execute(parsed_args)
|
||||
|
||||
def execute(self, parsed_args):
|
||||
return super(ClientExtensionList, self).get_data(parsed_args)
|
||||
|
||||
|
||||
class ClientExtensionDelete(NeutronClientExtension, neutronV20.DeleteCommand):
|
||||
def run(self, parsed_args):
|
||||
# NOTE(mdietz): Calls 'execute' to provide a consistent pattern
|
||||
# for any implementers adding extensions with
|
||||
# regard to any other extension verb.
|
||||
return self.execute(parsed_args)
|
||||
|
||||
def execute(self, parsed_args):
|
||||
return super(ClientExtensionDelete, self).run(parsed_args)
|
||||
|
||||
|
||||
class ClientExtensionCreate(NeutronClientExtension, neutronV20.CreateCommand):
|
||||
def get_data(self, parsed_args):
|
||||
# NOTE(mdietz): Calls 'execute' to provide a consistent pattern
|
||||
# for any implementers adding extensions with
|
||||
# regard to any other extension verb.
|
||||
return self.execute(parsed_args)
|
||||
|
||||
def execute(self, parsed_args):
|
||||
return super(ClientExtensionCreate, self).get_data(parsed_args)
|
||||
|
||||
|
||||
class ClientExtensionUpdate(NeutronClientExtension, neutronV20.UpdateCommand):
|
||||
def run(self, parsed_args):
|
||||
# NOTE(mdietz): Calls 'execute' to provide a consistent pattern
|
||||
# for any implementers adding extensions with
|
||||
# regard to any other extension verb.
|
||||
return self.execute(parsed_args)
|
||||
|
||||
def execute(self, parsed_args):
|
||||
return super(ClientExtensionUpdate, self).run(parsed_args)
|
0
neutronclient/neutron/v2_0/contrib/__init__.py
Normal file
0
neutronclient/neutron/v2_0/contrib/__init__.py
Normal file
85
neutronclient/neutron/v2_0/contrib/_fox_sockets.py
Normal file
85
neutronclient/neutron/v2_0/contrib/_fox_sockets.py
Normal file
@ -0,0 +1,85 @@
|
||||
# Copyright 2015 Rackspace Hosting Inc.
|
||||
# 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.
|
||||
#
|
||||
|
||||
from neutronclient.common import extension
|
||||
from neutronclient.i18n import _
|
||||
from neutronclient.neutron import v2_0 as neutronV20
|
||||
|
||||
|
||||
def _add_updatable_args(parser):
|
||||
parser.add_argument(
|
||||
'name',
|
||||
help=_('Name of this fox socket.'))
|
||||
|
||||
|
||||
def _updatable_args2body(parsed_args, body, client):
|
||||
if parsed_args.name:
|
||||
body['fox_socket'].update({'name': parsed_args.name})
|
||||
|
||||
|
||||
class FoxInSocket(extension.NeutronClientExtension):
|
||||
resource = 'fox_socket'
|
||||
resource_plural = '%ss' % resource
|
||||
object_path = '/%s' % resource_plural
|
||||
resource_path = '/%s/%%s' % resource_plural
|
||||
versions = ['2.0']
|
||||
|
||||
|
||||
class FoxInSocketsList(extension.ClientExtensionList, FoxInSocket):
|
||||
shell_command = 'fox-sockets-list'
|
||||
list_columns = ['id', 'name']
|
||||
pagination_support = True
|
||||
sorting_support = True
|
||||
|
||||
|
||||
class FoxInSocketsCreate(extension.ClientExtensionCreate, FoxInSocket):
|
||||
shell_command = 'fox-sockets-create'
|
||||
list_columns = ['id', 'name']
|
||||
|
||||
def add_known_arguments(self, parser):
|
||||
_add_updatable_args(parser)
|
||||
|
||||
def args2body(self, parsed_args):
|
||||
body = {'fox_socket': {}}
|
||||
client = self.get_client()
|
||||
_updatable_args2body(parsed_args, body, client)
|
||||
neutronV20.update_dict(parsed_args, body['fox_socket'], [])
|
||||
return body
|
||||
|
||||
|
||||
class FoxInSocketsUpdate(extension.ClientExtensionUpdate, FoxInSocket):
|
||||
shell_command = 'fox-sockets-update'
|
||||
list_columns = ['id', 'name']
|
||||
|
||||
def add_known_arguments(self, parser):
|
||||
#_add_updatable_args(parser)
|
||||
parser.add_argument(
|
||||
'--name',
|
||||
help=_('Name of this fox socket.'))
|
||||
|
||||
def args2body(self, parsed_args):
|
||||
body = {'fox_socket': {
|
||||
'name': parsed_args.name}, }
|
||||
neutronV20.update_dict(parsed_args, body['fox_socket'], [])
|
||||
return body
|
||||
|
||||
|
||||
class FoxInSocketsDelete(extension.ClientExtensionDelete, FoxInSocket):
|
||||
shell_command = 'fox-sockets-delete'
|
||||
|
||||
|
||||
class FoxInSocketsShow(extension.ClientExtensionShow, FoxInSocket):
|
||||
shell_command = 'fox-sockets-show'
|
@ -22,6 +22,8 @@ from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@ -40,6 +42,7 @@ from cliff import commandmanager
|
||||
from neutronclient.common import clientmanager
|
||||
from neutronclient.common import command as openstack_command
|
||||
from neutronclient.common import exceptions as exc
|
||||
from neutronclient.common import extension as client_extension
|
||||
from neutronclient.common import utils
|
||||
from neutronclient.i18n import _
|
||||
from neutronclient.neutron.v2_0 import agent
|
||||
@ -381,6 +384,8 @@ class NeutronShell(app.App):
|
||||
for k, v in self.commands[apiversion].items():
|
||||
self.command_manager.add_command(k, v)
|
||||
|
||||
self._register_extensions(VERSION)
|
||||
|
||||
# Pop the 'complete' to correct the outputs of 'neutron help'.
|
||||
self.command_manager.commands.pop('complete')
|
||||
|
||||
@ -671,6 +676,25 @@ class NeutronShell(app.App):
|
||||
options.add(option)
|
||||
print(' '.join(commands | options))
|
||||
|
||||
def _register_extensions(self, version):
|
||||
for name, module in itertools.chain(
|
||||
client_extension._discover_via_entry_points()):
|
||||
self._extend_shell_commands(module, version)
|
||||
|
||||
def _extend_shell_commands(self, module, version):
|
||||
classes = inspect.getmembers(module, inspect.isclass)
|
||||
for cls_name, cls in classes:
|
||||
if (issubclass(cls, client_extension.NeutronClientExtension) and
|
||||
hasattr(cls, 'shell_command')):
|
||||
cmd = cls.shell_command
|
||||
if hasattr(cls, 'versions'):
|
||||
if version not in cls.versions:
|
||||
continue
|
||||
try:
|
||||
self.command_manager.add_command(cmd, cls)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
def run(self, argv):
|
||||
"""Equivalent to the main program for the application.
|
||||
|
||||
|
@ -217,7 +217,8 @@ class CLITestV20Base(base.BaseTestCase):
|
||||
'credential', 'network_profile',
|
||||
'policy_profile', 'ikepolicy',
|
||||
'ipsecpolicy', 'metering_label',
|
||||
'metering_label_rule', 'net_partition']
|
||||
'metering_label_rule', 'net_partition',
|
||||
'fox_socket']
|
||||
if not cmd_resource:
|
||||
cmd_resource = resource
|
||||
if (resource in non_admin_status_resources):
|
||||
|
87
neutronclient/tests/unit/test_client_extension.py
Normal file
87
neutronclient/tests/unit/test_client_extension.py
Normal file
@ -0,0 +1,87 @@
|
||||
# Copyright 2015 Rackspace Hosting Inc.
|
||||
# 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 sys
|
||||
|
||||
import mock
|
||||
|
||||
from neutronclient.neutron.v2_0.contrib import _fox_sockets as fox_sockets
|
||||
from neutronclient.tests.unit import test_cli20
|
||||
|
||||
|
||||
class CLITestV20ExtensionJSON(test_cli20.CLITestV20Base):
|
||||
def setUp(self):
|
||||
# need to mock before super because extensions loaded on instantiation
|
||||
self._mock_extension_loading()
|
||||
super(CLITestV20ExtensionJSON, self).setUp(plurals={'tags': 'tag'})
|
||||
|
||||
def _create_patch(self, name, func=None):
|
||||
patcher = mock.patch(name)
|
||||
thing = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
return thing
|
||||
|
||||
def _mock_extension_loading(self):
|
||||
ext_pkg = 'neutronclient.common.extension'
|
||||
contrib = self._create_patch(ext_pkg + '._discover_via_entry_points')
|
||||
iterator = iter([("_fox_sockets", fox_sockets)])
|
||||
contrib.return_value.__iter__.return_value = iterator
|
||||
return contrib
|
||||
|
||||
def test_delete_fox_socket(self):
|
||||
"""Delete fox socket: myid."""
|
||||
resource = 'fox_socket'
|
||||
cmd = fox_sockets.FoxInSocketsDelete(test_cli20.MyApp(sys.stdout),
|
||||
None)
|
||||
myid = 'myid'
|
||||
args = [myid]
|
||||
self._test_delete_resource(resource, cmd, myid, args)
|
||||
|
||||
def test_update_fox_socket(self):
|
||||
"""Update fox_socket: myid --name myname."""
|
||||
resource = 'fox_socket'
|
||||
cmd = fox_sockets.FoxInSocketsUpdate(test_cli20.MyApp(sys.stdout),
|
||||
None)
|
||||
self._test_update_resource(resource, cmd, 'myid',
|
||||
['myid', '--name', 'myname'],
|
||||
{'name': 'myname'})
|
||||
|
||||
def test_create_fox_socket(self):
|
||||
"""Create fox_socket: myname."""
|
||||
resource = 'fox_socket'
|
||||
cmd = fox_sockets.FoxInSocketsCreate(test_cli20.MyApp(sys.stdout),
|
||||
None)
|
||||
name = 'myname'
|
||||
myid = 'myid'
|
||||
args = [name, ]
|
||||
position_names = ['name', ]
|
||||
position_values = [name, ]
|
||||
self._test_create_resource(resource, cmd, name, myid, args,
|
||||
position_names, position_values)
|
||||
|
||||
def test_list_fox_sockets(self):
|
||||
"""List fox_sockets."""
|
||||
resources = 'fox_sockets'
|
||||
cmd = fox_sockets.FoxInSocketsList(test_cli20.MyApp(sys.stdout), None)
|
||||
self._test_list_resources(resources, cmd, True)
|
||||
|
||||
def test_show_fox_socket(self):
|
||||
"""Show fox_socket: --fields id --fields name myid."""
|
||||
resource = 'fox_socket'
|
||||
cmd = fox_sockets.FoxInSocketsShow(test_cli20.MyApp(sys.stdout), None)
|
||||
args = ['--fields', 'id', '--fields', 'name', self.test_id]
|
||||
self._test_show_resource(resource, cmd, self.test_id,
|
||||
args, ['id', 'name'])
|
@ -15,6 +15,8 @@
|
||||
# under the License.
|
||||
#
|
||||
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import time
|
||||
|
||||
@ -24,6 +26,7 @@ import six.moves.urllib.parse as urlparse
|
||||
from neutronclient import client
|
||||
from neutronclient.common import constants
|
||||
from neutronclient.common import exceptions
|
||||
from neutronclient.common import extension as client_extension
|
||||
from neutronclient.common import serializer
|
||||
from neutronclient.common import utils
|
||||
from neutronclient.i18n import _
|
||||
@ -453,6 +456,36 @@ class Client(ClientBase):
|
||||
'healthmonitors': 'healthmonitor',
|
||||
}
|
||||
|
||||
@APIParamsCall
|
||||
def list_ext(self, path, **_params):
|
||||
"""Client extension hook for lists.
|
||||
"""
|
||||
return self.get(path, params=_params)
|
||||
|
||||
@APIParamsCall
|
||||
def show_ext(self, path, id, **_params):
|
||||
"""Client extension hook for shows.
|
||||
"""
|
||||
return self.get(path % id, params=_params)
|
||||
|
||||
@APIParamsCall
|
||||
def create_ext(self, path, body=None):
|
||||
"""Client extension hook for creates.
|
||||
"""
|
||||
return self.post(path, body=body)
|
||||
|
||||
@APIParamsCall
|
||||
def update_ext(self, path, id, body=None):
|
||||
"""Client extension hook for updates.
|
||||
"""
|
||||
return self.put(path % id, body=body)
|
||||
|
||||
@APIParamsCall
|
||||
def delete_ext(self, path, id):
|
||||
"""Client extension hook for deletes.
|
||||
"""
|
||||
return self.delete(path % id)
|
||||
|
||||
@APIParamsCall
|
||||
def get_quotas_tenant(self, **_params):
|
||||
"""Fetch tenant info in server's context for following quota operation.
|
||||
@ -1523,3 +1556,59 @@ class Client(ClientBase):
|
||||
def delete_packet_filter(self, packet_filter_id):
|
||||
"""Delete the specified packet filter."""
|
||||
return self.delete(self.packet_filter_path % packet_filter_id)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new client for the Neutron v2.0 API."""
|
||||
super(Client, self).__init__(**kwargs)
|
||||
self._register_extensions(self.version)
|
||||
|
||||
def extend_show(self, resource_plural, path):
|
||||
def _fx(obj, **_params):
|
||||
return self.show_ext(path, obj, **_params)
|
||||
setattr(self, "show_%s" % resource_plural, _fx)
|
||||
|
||||
def extend_list(self, resource_plural, path):
|
||||
def _fx(**_params):
|
||||
return self.list_ext(path, **_params)
|
||||
setattr(self, "list_%s" % resource_plural, _fx)
|
||||
|
||||
def extend_create(self, resource_singular, path):
|
||||
def _fx(body=None):
|
||||
return self.create_ext(path, body)
|
||||
setattr(self, "create_%s" % resource_singular, _fx)
|
||||
|
||||
def extend_delete(self, resource_singular, path):
|
||||
def _fx(obj):
|
||||
return self.delete_ext(path, obj)
|
||||
setattr(self, "delete_%s" % resource_singular, _fx)
|
||||
|
||||
def extend_update(self, resource_singular, path):
|
||||
def _fx(obj, body=None):
|
||||
return self.update_ext(path, obj, body)
|
||||
setattr(self, "update_%s" % resource_singular, _fx)
|
||||
|
||||
def _extend_client_with_module(self, module, version):
|
||||
classes = inspect.getmembers(module, inspect.isclass)
|
||||
for cls_name, cls in classes:
|
||||
if hasattr(cls, 'versions'):
|
||||
if version not in cls.versions:
|
||||
continue
|
||||
if issubclass(cls, client_extension.ClientExtensionList):
|
||||
self.extend_list(cls.resource_plural, cls.object_path)
|
||||
elif issubclass(cls, client_extension.ClientExtensionCreate):
|
||||
self.extend_create(cls.resource, cls.object_path)
|
||||
elif issubclass(cls, client_extension.ClientExtensionUpdate):
|
||||
self.extend_update(cls.resource, cls.resource_path)
|
||||
elif issubclass(cls, client_extension.ClientExtensionDelete):
|
||||
self.extend_delete(cls.resource, cls.resource_path)
|
||||
elif issubclass(cls, client_extension.ClientExtensionShow):
|
||||
self.extend_show(cls.resource, cls.resource_path)
|
||||
elif issubclass(cls, client_extension.NeutronClientExtension):
|
||||
setattr(self, "%s_path" % cls.resource_plural,
|
||||
cls.object_path)
|
||||
setattr(self, "%s_path" % cls.resource, cls.resource_path)
|
||||
|
||||
def _register_extensions(self, version):
|
||||
for name, module in itertools.chain(
|
||||
client_extension._discover_via_entry_points()):
|
||||
self._extend_client_with_module(module, version)
|
||||
|
@ -8,6 +8,7 @@ coverage>=3.6
|
||||
discover
|
||||
fixtures>=0.3.14
|
||||
mox3>=0.7.0
|
||||
mock>=1.0
|
||||
oslosphinx>=2.2.0 # Apache-2.0
|
||||
oslotest>=1.2.0 # Apache-2.0
|
||||
python-subunit>=0.0.18
|
||||
|
Loading…
Reference in New Issue
Block a user