Merge "Add devices-list to support /v1/devices"

This commit is contained in:
Jenkins 2017-03-03 09:44:11 +00:00 committed by Gerrit Code Review
commit adaf64c626
10 changed files with 692 additions and 13 deletions

View File

@ -105,12 +105,15 @@ class CRUDClient(object):
def list(self, skip_merge=False, **kwargs):
"""Generate the items from this endpoint."""
autopaginate = kwargs.pop('autopaginate', True)
nested = kwargs.pop('nested', False)
self.merge_request_arguments(kwargs, skip_merge)
url = self.build_url(path_arguments=kwargs)
response_generator = self.session.paginate(
url,
autopaginate=autopaginate,
items_key=(self.key + 's'),
nested=nested,
params=kwargs,
)
for response, items in response_generator:

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
"""Craton-specific session details."""
from itertools import chain
import logging
from keystoneauth1 import session as ksa_session
@ -233,7 +234,8 @@ class Session(object):
return response
def paginate(self, url, items_key, autopaginate=True, **kwargs):
def paginate(self, url, items_key, autopaginate=True, nested=False,
**kwargs):
"""Make a GET request to a paginated resource.
If :param:`autopaginate` is set to ``True``, this will automatically
@ -256,21 +258,24 @@ class Session(object):
Determines whether or not this method continues requesting items
automatically after the first page.
"""
response = self.get(url, **kwargs)
json_body = response.json()
items = json_body[items_key]
links = json_body['links']
yield response, items
while autopaginate and len(items) > 0:
url = _find_next_link(links)
if url is None:
break
response = self.get(url)
get_items = True
while get_items:
response = self.get(url, **kwargs)
json_body = response.json()
items = json_body[items_key]
links = json_body['links']
if nested:
items = list(chain(*json_body[items_key].values()))
else:
items = json_body[items_key]
yield response, items
links = json_body['links']
url = _find_next_link(links)
kwargs = {}
get_items = url and autopaginate and len(items) > 0
def _find_next_link(links):
for link in links:

View File

@ -0,0 +1,122 @@
# 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.
"""Hosts resource and resource shell wrapper."""
from __future__ import print_function
from cratonclient.common import cliutils
from cratonclient import exceptions as exc
from cratonclient.v1 import devices
@cliutils.arg('--fields',
nargs='+',
metavar='<fields>',
default=[],
help='Space-separated list of fields to display. '
'Only these fields will be fetched from the server.')
@cliutils.arg('--all',
action='store_true',
default=False,
help='Retrieve and show all devices. This will override '
'the provided value for --limit and automatically '
'retrieve each page of results.')
@cliutils.arg('--sort-key',
metavar='<field>',
help='Device field that will be used for sorting.')
@cliutils.arg('--sort-dir',
metavar='<direction>',
default='asc',
choices=('asc', 'desc'),
help='Sort direction: "asc" (default) or "desc".')
@cliutils.arg('--limit',
metavar='<limit>',
type=int,
help='Maximum number of devices to return.')
@cliutils.arg('--marker',
metavar='<marker>',
default=None,
help='ID of the device to use to resume listing devices.')
@cliutils.arg('--cloud',
metavar='<cloud>',
type=int,
help='ID of the cloud that the device belongs to.')
@cliutils.arg('-r', '--region',
metavar='<region>',
type=int,
help='ID of the region that the device belongs to.')
@cliutils.arg('-c', '--cell',
metavar='<cell>',
type=int,
help='Integer ID of the cell that contains '
'the desired list of devices.')
@cliutils.arg('--parent',
metavar='<parent>',
type=int,
help='Parent ID of required devices.')
@cliutils.arg('--descendants',
default=False,
action='store_true',
help='When parent is also specified, include all descendants.')
@cliutils.arg('--active',
metavar='<active>',
choices=("true", "false"),
help='Filter devices by their active state.')
def do_device_list(cc, args):
"""List all devices."""
params = {}
default_fields = [
'cloud_id', 'region_id', 'cell_id', 'parent_id', 'id', 'name',
'device_type', 'active',
]
if args.limit is not None:
if args.limit < 0:
raise exc.CommandError('Invalid limit specified. Expected '
'non-negative limit, got {0}'
.format(args.limit))
params['limit'] = args.limit
if args.all is True:
params['limit'] = 100
if args.fields:
try:
fields = {x: devices.DEVICE_FIELDS[x] for x in args.fields}
except KeyError as err:
raise exc.CommandError('Invalid field "{}"'.format(err.args[0]))
else:
fields = default_fields
sort_key = args.sort_key and args.sort_key.lower()
if sort_key is not None:
if sort_key not in devices.DEVICE_FIELDS:
raise exc.CommandError(
'{0} is an invalid key for sorting, valid values for '
'--sort-key are: {1}'.format(
args.sort_key, devices.DEVICE_FIELDS.keys()
)
)
params['sort_keys'] = sort_key
params['sort_dir'] = args.sort_dir
params['marker'] = args.marker
params['autopaginate'] = args.all
if args.parent:
params['parent_id'] = args.parent
params['descendants'] = args.descendants
if args.cloud:
params['cloud_id'] = args.cloud
if args.region:
params['region_id'] = args.region
if args.cell:
params['cell_id'] = args.cell
if args.active:
params['active'] = args.active
devices_list = cc.devices.list(**params)
args.formatter.configure(fields=list(fields)).handle(devices_list)

View File

@ -12,6 +12,7 @@
"""Command-line interface to the OpenStack Craton API V1."""
from cratonclient.shell.v1 import cells_shell
from cratonclient.shell.v1 import clouds_shell
from cratonclient.shell.v1 import devices_shell
from cratonclient.shell.v1 import hosts_shell
from cratonclient.shell.v1 import projects_shell
from cratonclient.shell.v1 import regions_shell
@ -22,6 +23,7 @@ COMMAND_MODULES = [
projects_shell,
clouds_shell,
regions_shell,
devices_shell,
hosts_shell,
cells_shell,
]

View File

@ -0,0 +1,182 @@
# 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.
"""Tests for `cratonclient.shell.v1.devices_shell` module."""
import mock
import re
from cratonclient import exceptions as exc
from cratonclient.tests.integration.shell import base
class TestDevicesShell(base.ShellTestCase):
"""Test our craton devices shell commands."""
re_options = re.DOTALL | re.MULTILINE
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_success(self, mock_list):
"""Verify that no arguments prints out all project devices."""
self.shell('device-list')
self.assertTrue(mock_list.called)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_parse_param_success(self, mock_list):
"""Verify that success of parsing a subcommand argument."""
self.shell('device-list --limit 0')
self.assertTrue(mock_list.called)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_limit_0_success(self, mock_list):
"""Verify that --limit 0 prints out all project devices."""
self.shell('device-list --limit 0')
mock_list.assert_called_once_with(
limit=0,
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_limit_positive_num_success(self, mock_list):
"""Verify --limit X, where X is a positive integer, succeeds.
The command will print out X number of project devices.
"""
self.shell('device-list --limit 1')
mock_list.assert_called_once_with(
limit=1,
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
def test_device_list_limit_negative_num_failure(self):
"""Verify --limit X, where X is a negative integer, fails.
The command will cause a Command Error message response.
"""
self.assertRaises(exc.CommandError,
self.shell,
'device-list -r 1 --limit -1')
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_cell_success(self, mock_list):
"""Verify --cell arguments successfully pass cell to Client."""
for cell_arg in ['-c', '--cell']:
self.shell('device-list {0} 1'.format(cell_arg))
mock_list.assert_called_once_with(
cell_id=1,
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
mock_list.reset_mock()
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_fields_success(self, mock_list):
"""Verify --fields argument successfully passed to Client."""
self.shell('device-list --fields id name')
mock_list.assert_called_once_with(
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_keys_field_key_success(self, mock_list):
"""Verify --sort-key arguments successfully passed to Client."""
self.shell('device-list --sort-key cell_id')
mock_list.assert_called_once_with(
sort_keys='cell_id',
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
def test_device_list_sort_keys_invalid(self):
"""Verify --sort-key with invalid args, fails with Command Error."""
self.assertRaises(exc.CommandError,
self.shell,
'device-list --sort-key invalid')
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_dir_not_passed_without_sort_key(self, mock_list):
"""Verify --sort-dir arg ignored without --sort-key."""
self.shell('device-list --sort-dir desc')
mock_list.assert_called_once_with(
sort_dir='desc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_dir_asc_success(self, mock_list):
"""Verify --sort-dir asc successfully passed to Client."""
self.shell('device-list --sort-key name --sort-dir asc')
mock_list.assert_called_once_with(
sort_keys='name',
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_dir_desc_success(self, mock_list):
"""Verify --sort-dir desc successfully passed to Client."""
self.shell('device-list --sort-key name --sort-dir desc')
mock_list.assert_called_once_with(
sort_keys='name',
sort_dir='desc',
descendants=False,
marker=None,
autopaginate=False,
)
def test_device_list_sort_dir_invalid_value(self):
"""Verify --sort-dir with invalid args, fails with Command Error."""
(_, error) = self.shell(
'device-list -r 1 --sort-key name --sort-dir invalid'
)
self.assertIn("invalid choice: 'invalid'", error)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_filter_by_parent_success(self, mock_list):
"""Verify --parent ID successfully passed to Client."""
self.shell('device-list --parent 12345')
mock_list.assert_called_once_with(
sort_dir='asc',
descendants=False,
parent_id=12345,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_filter_by_parent_descendants_success(self, mock_list):
"""Verify --parent ID successfully passed to Client."""
self.shell('device-list --parent 12345 --descendants')
mock_list.assert_called_once_with(
sort_dir='asc',
parent_id=12345,
descendants=True,
marker=None,
autopaginate=False,
)

View File

@ -0,0 +1,257 @@
# -*- coding: utf-8 -*-
# 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.
"""Tests for the shell functions for the devices resource."""
from cratonclient.shell.v1 import devices_shell
from cratonclient.tests.unit.shell import base
class TestDoDeviceList(base.TestShellCommandUsingPrintList):
"""Unit tests for the device list command."""
def args_for(self, **kwargs):
"""Generate a Namespace for do_device_list."""
kwargs.setdefault('cloud', None)
kwargs.setdefault('region', None)
kwargs.setdefault('cell', None)
kwargs.setdefault('parent', None)
kwargs.setdefault('descendants', False)
kwargs.setdefault('active', None)
kwargs.setdefault('limit', None)
kwargs.setdefault('sort_key', None)
kwargs.setdefault('sort_dir', 'asc')
kwargs.setdefault('fields', [])
kwargs.setdefault('marker', None)
kwargs.setdefault('all', False)
return super(TestDoDeviceList, self).args_for(**kwargs)
def test_only_required_parameters(self):
"""Verify the behaviour with the minimum number of params."""
args = self.args_for()
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_parent_id(self):
"""Verify that we include the parent_id in the params."""
args = self.args_for(parent=789)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
parent_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_parent_id_and_descendants(self):
"""Verify that the parent_id and descendants is in the params."""
args = self.args_for(parent=789, descendants=False)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
parent_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_region_id(self):
"""Verify that we include the region_id in the params."""
args = self.args_for(region=789)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
region_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_cell_id(self):
"""Verify that we include the cell_id in the params."""
args = self.args_for(cell=789)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
cell_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_cloud_id(self):
"""Verify that we include the cell_id in the params."""
args = self.args_for(cloud=123)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
cloud_id=123,
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_limit(self):
"""Verify the behaviour with --limit specified."""
args = self.args_for(limit=20)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
limit=20,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_negative_limit_raises_command_error(self):
"""Verify that we forbid negative limit values."""
args = self.args_for(limit=-10)
self.assertRaisesCommandErrorWith(devices_shell.do_device_list, args)
self.assertNothingWasCalled()
def test_fields(self):
"""Verify that we can specify custom fields."""
args = self.args_for(fields=['id', 'name', 'cell_id'])
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'cell_id', 'id', 'name',
])
def test_invalid_sort_key(self):
"""Verify that we disallow invalid sort keys."""
args = self.args_for(sort_key='my-fake-sort-key')
self.assertRaisesCommandErrorWith(
devices_shell.do_device_list, args
)
self.assertNothingWasCalled()
def test_sort_key(self):
"""Verify we pass sort_key to our list call."""
args = self.args_for(sort_key='ip_address')
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_keys='ip_address',
sort_dir='asc',
autopaginate=False,
marker=None,
)
def test_invalid_fields_raise_command_error(self):
"""Verify sending an invalid field raises a CommandError."""
args = self.args_for(fields=['fake-field', 'id'])
self.assertRaisesCommandErrorWith(
devices_shell.do_device_list, args,
)
self.assertNothingWasCalled()
def test_autopagination(self):
"""Verify autopagination is controlled by --all."""
args = self.args_for(all=True)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
limit=100,
marker=None,
autopaginate=True,
)
def test_autopagination_overrides_limit(self):
"""Verify --all overrides --limit."""
args = self.args_for(all=True, limit=30)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
limit=100,
marker=None,
autopaginate=True,
)
def test_marker_pass_through(self):
"""Verify we pass our marker through to the client."""
args = self.args_for(marker=42)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
marker=42,
autopaginate=False,
)

View File

@ -183,6 +183,7 @@ class TestCRUDClient(base.TestCase):
autopaginate=True,
items_key='test_keys',
params={'sort': 'asc'},
nested=False,
)
self.resource_spec.assert_called_once_with(
self.client,

View File

@ -12,6 +12,9 @@
# License for the specific language governing permissions and limitations
# under the License.
"""Session specific unit tests."""
from itertools import chain
from operator import itemgetter
from keystoneauth1 import session as ksa_session
import mock
@ -162,3 +165,57 @@ class TestSession(base.TestCase):
),
],
)
def test_paginate_nested_response(self):
"""Verify Session#paginate can extract nested lists."""
responses = [
self.create_response(
{
"items-sub-type-1": [
{"id": 1},
],
"items-sub-type-2": [
{"id": 2},
],
},
"http://example.com/v1/items?limit=30&marker=2"
),
self.create_response(
{
"items-sub-type-1": [],
"items-sub-type-2": [],
},
""
),
]
mock_session = mock.Mock()
mock_session.request.side_effect = responses
craton_session = session.Session(session=mock_session)
paginated_items = list(craton_session.paginate(
url='http://example.com/v1/items',
items_key='items',
autopaginate=True,
nested=True,
))
self.assertEqual(2, len(paginated_items))
resp_items = sorted(
chain(*(resp[1] for resp in paginated_items)), key=itemgetter("id")
)
self.assertListEqual([{"id": 1}, {"id": 2}], resp_items)
self.assertListEqual(
mock_session.request.call_args_list,
[
mock.call(
method='GET',
url='http://example.com/v1/items',
endpoint_filter={'service_type': 'fleet_management'},
),
mock.call(
method='GET',
url='http://example.com/v1/items?limit=30&marker=2',
endpoint_filter={'service_type': 'fleet_management'},
),
]
)

View File

@ -14,6 +14,7 @@
"""Top-level client for version 1 of Craton's API."""
from cratonclient.v1 import cells
from cratonclient.v1 import clouds
from cratonclient.v1 import devices
from cratonclient.v1 import hosts
from cratonclient.v1 import projects
from cratonclient.v1 import regions
@ -44,4 +45,5 @@ class Client(object):
self.cells = cells.CellManager(**manager_kwargs)
self.projects = projects.ProjectManager(**manager_kwargs)
self.clouds = clouds.CloudManager(**manager_kwargs)
self.devices = devices.DeviceManager(**manager_kwargs)
self.regions = regions.RegionManager(**manager_kwargs)

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# 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.
"""Devices manager code."""
from cratonclient import crud
class Device(crud.Resource):
"""Representation of a Device."""
pass
class DeviceManager(crud.CRUDClient):
"""A manager for devices."""
key = 'device'
base_path = '/devices'
resource_class = Device
def list(self, **kwargs):
"""Generate the items from this endpoint."""
return super(DeviceManager, self).list(nested=True, **kwargs)
DEVICE_FIELDS = {
'id': 'ID',
'project_id': 'Project ID',
'cloud_id': 'Cloud ID',
'region_id': 'Region ID',
'cell_id': 'Cell ID',
'parent_id': 'Parent ID',
'name': 'Name',
'ip_address': 'IP Address',
'device_type': 'Device Type',
'note': 'Note',
'created_at': 'Created At',
'updated_at': 'Updated At'
}